Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c1d08496c9 | |||
| 0567593edd | |||
| 6f2037309c | |||
| e6965fe3b8 | |||
| a89376e4c4 | |||
| c07d4b802b | |||
| cc8ced184d | |||
| 335a0c2f7e | |||
| cc98dc4ccf | |||
| 2b1c2b72c1 | |||
| 8960744a81 | |||
| 23c03efe84 | |||
| be50b0e056 | |||
| 35864e61dd | |||
| 481452eea7 | |||
| 9b58055360 | |||
| e5eaf80199 | |||
| 74a61844a0 | |||
| ee7b7eebad | |||
| 642b41ffaf | |||
| f0b03e27b3 | |||
| ccf18fb768 | |||
| b09995920c | |||
| 7ccb2f445e | |||
| 048f5435ff | |||
| 886a3ee541 | |||
| ee90708751 | |||
| 3abc2e2f2f | |||
| 32b3bff318 | |||
| ecc348753e | |||
| b490bf01c7 | |||
| b2d11abd3f | |||
| 8bc200d325 | |||
| 27fe1a3a67 | |||
| d7b3f05511 | |||
| 9203a9d3fa | |||
| fdb4261434 | |||
| b6cc9bb715 | |||
| f15fcc55c4 | |||
| 58f4643d50 | |||
| c7f7f35785 | |||
| 6ed95784a3 | |||
| e56df858fd | |||
| 42c38f7da8 | |||
| df596a90d5 | |||
| 6d63e353a3 | |||
| 2da063287e | |||
| 599677f9c2 | |||
| 17c1be7b3f | |||
| 85cb116e28 | |||
| 3b3bbc1bfa | |||
| e97580b212 | |||
| 2ea4eefb7a | |||
| 1a4fce3e13 | |||
| b07336f9f1 | |||
| 2488b3e75c | |||
| 4d2d9d2ae8 | |||
| 1a4a8870ba | |||
| 3ba2207ae4 | |||
| f9e51b8fa9 | |||
| 406aa47c33 | |||
| 6796213ccf | |||
| b57d58aa92 | |||
| fc4bdea66f |
@@ -1,2 +1,7 @@
|
||||
__version__ = "v2.0.5"
|
||||
import django
|
||||
|
||||
|
||||
__version__ = "1.0.0"
|
||||
|
||||
if django.VERSION < (3, 2):
|
||||
default_app_config = "azbankgateways.apps.AZIranianBankGatewaysConfig"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from django.contrib import admin
|
||||
from utils.admin import ModelAdmin
|
||||
|
||||
from .models import Bank
|
||||
|
||||
|
||||
class BankAdmin(ModelAdmin):
|
||||
class BankAdmin(admin.ModelAdmin):
|
||||
fields = [
|
||||
"pk",
|
||||
"status",
|
||||
@@ -17,7 +17,6 @@ class BankAdmin(ModelAdmin):
|
||||
"bank_choose_identifier",
|
||||
"created_at",
|
||||
"update_at",
|
||||
'order'
|
||||
]
|
||||
list_display = [
|
||||
"pk",
|
||||
@@ -32,7 +31,6 @@ class BankAdmin(ModelAdmin):
|
||||
"bank_choose_identifier",
|
||||
"created_at",
|
||||
"update_at",
|
||||
'order'
|
||||
]
|
||||
list_filter = [
|
||||
"status",
|
||||
@@ -66,7 +64,6 @@ class BankAdmin(ModelAdmin):
|
||||
"extra_information",
|
||||
"created_at",
|
||||
"update_at",
|
||||
'order',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
from django.http import request
|
||||
from azbankgateways.banks import BaseBank
|
||||
from azbankgateways.models import BankType
|
||||
from azbankgateways.bankfactories import BankFactory as BaseBankFactory
|
||||
|
||||
|
||||
class BankFactory(BaseBankFactory):
|
||||
def create(
|
||||
self,
|
||||
request: request,
|
||||
amount: int,
|
||||
callback_url : str,
|
||||
mobile_number: str = None,
|
||||
bank_type: BankType = None,
|
||||
identifier: str = "1",
|
||||
) -> BaseBank:
|
||||
bank = super().create(bank_type, identifier)
|
||||
|
||||
bank = self.set_payment_info(
|
||||
bank=bank,
|
||||
request=request,
|
||||
amount=amount,
|
||||
callback_url=callback_url,
|
||||
mobile_number=mobile_number,
|
||||
)
|
||||
return bank
|
||||
|
||||
def auto_create(
|
||||
self,
|
||||
request: request,
|
||||
amount: int,
|
||||
callback_url : str,
|
||||
mobile_number: str = None,
|
||||
identifier: str = "1",
|
||||
) -> BaseBank:
|
||||
|
||||
bank = super().auto_create(identifier, amount)
|
||||
|
||||
bank = self.set_payment_info(
|
||||
bank=bank,
|
||||
request=request,
|
||||
amount=amount,
|
||||
callback_url=callback_url,
|
||||
mobile_number=mobile_number,
|
||||
)
|
||||
return bank
|
||||
|
||||
def set_payment_info(
|
||||
self,
|
||||
bank: BaseBank,
|
||||
request: request,
|
||||
amount: int,
|
||||
callback_url : str,
|
||||
mobile_number: str = None,
|
||||
):
|
||||
bank.set_request(request=request)
|
||||
bank.set_amount(amount=amount)
|
||||
bank.set_client_callback_url(callback_url=callback_url)
|
||||
bank.set_mobile_number(mobile_number=mobile_number)
|
||||
return bank
|
||||
@@ -1,8 +1,31 @@
|
||||
from .bahamta import Bahamta # noqa
|
||||
from .banks import BaseBank # noqa
|
||||
from .bmi import BMI # noqa
|
||||
from .idpay import IDPay # noqa
|
||||
from .mellat import Mellat # noqa
|
||||
from .sep import SEP # noqa
|
||||
from .zarinpal import Zarinpal # noqa
|
||||
from .zibal import Zibal # noqa
|
||||
"""
|
||||
This package exposes bank gateway classes.
|
||||
|
||||
NOTE:
|
||||
`from .banks import BaseBank` **must appear first** to avoid circular-import
|
||||
issues. Other classes depend on `BaseBank`, so importing it earlier prevents
|
||||
initialization-order problems.
|
||||
"""
|
||||
|
||||
# isort: off
|
||||
from .banks import BaseBank
|
||||
from .asanpardakht import AsanPardakht
|
||||
from .bahamta import Bahamta
|
||||
from .bmi import BMI
|
||||
from .mellat import Mellat
|
||||
from .sep import SEP
|
||||
from .zarinpal import Zarinpal
|
||||
from .zibal import Zibal
|
||||
|
||||
# isort: on
|
||||
|
||||
__all__ = [
|
||||
"BaseBank",
|
||||
"AsanPardakht",
|
||||
"Bahamta",
|
||||
"BMI",
|
||||
"Mellat",
|
||||
"SEP",
|
||||
"Zarinpal",
|
||||
"Zibal",
|
||||
]
|
||||
|
||||
Executable
+175
@@ -0,0 +1,175 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
from azbankgateways.banks import BaseBank
|
||||
from azbankgateways.exceptions import (
|
||||
AZBankGatewaysException,
|
||||
BankGatewayConnectionError,
|
||||
BankGatewayRejectPayment,
|
||||
SettingDoesNotExist,
|
||||
)
|
||||
from azbankgateways.models import BankType, CurrencyEnum, PaymentStatus
|
||||
|
||||
|
||||
class AsanPardakht(BaseBank):
|
||||
_merchant_configuration_id = None
|
||||
_username = None
|
||||
_password = None
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super(AsanPardakht, self).__init__(**kwargs)
|
||||
|
||||
self.set_gateway_currency(CurrencyEnum.IRR)
|
||||
self._token_api_url = "https://ipgrest.asanpardakht.ir/v1/Token"
|
||||
self._payment_url = "https://asan.shaparak.ir"
|
||||
self._verify_api_url = "https://ipgrest.asanpardakht.ir/v1/Verify"
|
||||
self._local_date_api_url = "https://ipgrest.asanpardakht.ir/v1/Time"
|
||||
self._transaction_result_api_url = "https://ipgrest.asanpardakht.ir/v1/TranResult"
|
||||
self._settlement_api_url = "https://ipgrest.asanpardakht.ir/v1/Settlement"
|
||||
|
||||
def get_bank_type(self):
|
||||
return BankType.ASANPARDAKHT
|
||||
|
||||
def set_default_settings(self):
|
||||
required_settings = ["MERCHANT_CONFIGURATION_ID", "USERNAME", "PASSWORD"]
|
||||
for item in required_settings:
|
||||
if item not in self.default_setting_kwargs:
|
||||
raise SettingDoesNotExist(f"{item} is not set in settings.")
|
||||
|
||||
setattr(self, f"_{item.lower()}", self.default_setting_kwargs[item])
|
||||
|
||||
def get_pay_data(self):
|
||||
data = {
|
||||
"serviceTypeId": 1, # Service type code. For making a purchase, send code 1.
|
||||
"merchantConfigurationId": self._merchant_configuration_id,
|
||||
"localInvoiceId": self.get_tracking_code(),
|
||||
"amountInRials": self.get_gateway_amount(),
|
||||
"localDate": self._get_local_date(),
|
||||
"callbackURL": self._get_gateway_callback_url() + f'&localInvoiceId={self.get_tracking_code()}',
|
||||
"paymentId": self.get_tracking_code(),
|
||||
**self.get_custom_data(),
|
||||
}
|
||||
return data
|
||||
|
||||
def prepare_pay(self):
|
||||
super(AsanPardakht, self).prepare_pay()
|
||||
|
||||
def pay(self):
|
||||
super(AsanPardakht, self).pay()
|
||||
data = self.get_pay_data()
|
||||
token = self._send_request(self._token_api_url, data)
|
||||
if token:
|
||||
self._set_reference_number(token)
|
||||
else:
|
||||
status_text = "Failed to retrieve token from Asan Pardakht"
|
||||
self._set_transaction_status_text(status_text)
|
||||
logging.critical(status_text)
|
||||
raise BankGatewayRejectPayment(self.get_transaction_status_text())
|
||||
|
||||
def _get_gateway_payment_url_parameter(self):
|
||||
return self._payment_url
|
||||
|
||||
def _get_gateway_payment_method_parameter(self):
|
||||
return "POST"
|
||||
|
||||
def _get_gateway_payment_parameter(self):
|
||||
params = {
|
||||
"RefId": self.get_reference_number(),
|
||||
}
|
||||
return params
|
||||
|
||||
def prepare_verify_from_gateway(self):
|
||||
super(AsanPardakht, self).prepare_verify_from_gateway()
|
||||
request = self.get_request()
|
||||
tracking_code = request.GET.get("localInvoiceId")
|
||||
self._set_tracking_code(tracking_code)
|
||||
self._set_bank_record()
|
||||
self._check_transaction_data()
|
||||
|
||||
def verify_from_gateway(self, request):
|
||||
super(AsanPardakht, self).verify_from_gateway(request)
|
||||
|
||||
def get_verify_data(self):
|
||||
data = {
|
||||
"merchantConfigurationId": self._merchant_configuration_id,
|
||||
"payGateTranId": self._get_pay_gate_tran_id(),
|
||||
}
|
||||
return data
|
||||
|
||||
def prepare_verify(self, tracking_code):
|
||||
super(AsanPardakht, self).prepare_verify(tracking_code)
|
||||
|
||||
def verify(self, transaction_code):
|
||||
super(AsanPardakht, self).verify(transaction_code)
|
||||
data = self.get_verify_data()
|
||||
self._send_request(self._verify_api_url, data, is_json=False)
|
||||
self._set_payment_status(PaymentStatus.COMPLETE)
|
||||
self._settle_transaction()
|
||||
|
||||
def _send_request(self, api_url, data, method='POST', is_json=True):
|
||||
headers = {
|
||||
"usr": self._username,
|
||||
"pwd": self._password,
|
||||
}
|
||||
try:
|
||||
response = requests.request(
|
||||
method, api_url, json=data, headers=headers, timeout=self.get_timeout()
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.Timeout:
|
||||
logging.exception(f"Asan Pardakht gateway timeout: {data}")
|
||||
raise BankGatewayConnectionError()
|
||||
except requests.ConnectionError:
|
||||
logging.exception(f"Asan Pardakht gateway connection error: {data}")
|
||||
raise BankGatewayConnectionError()
|
||||
except requests.HTTPError as e:
|
||||
logging.exception(f"HTTP error occurred: {e}")
|
||||
raise BankGatewayConnectionError()
|
||||
if is_json:
|
||||
return response.json()
|
||||
return response.text
|
||||
|
||||
def _get_local_date(self):
|
||||
return self._send_request(self._local_date_api_url, {}, method='GET')
|
||||
|
||||
def _get_transaction_data(self):
|
||||
data = {
|
||||
'merchantConfigurationId': self._merchant_configuration_id,
|
||||
'localInvoiceId': self.get_tracking_code(),
|
||||
}
|
||||
return self._send_request(self._transaction_result_api_url, data, method='GET')
|
||||
|
||||
def _check_transaction_data(self):
|
||||
transaction_data = self._get_transaction_data()
|
||||
is_valid = (
|
||||
transaction_data
|
||||
and self._bank.reference_number == transaction_data.get('refID')
|
||||
and transaction_data.get('amount') is not None
|
||||
and int(self._bank.amount) == transaction_data.get('amount')
|
||||
)
|
||||
if not is_valid:
|
||||
error_message = (
|
||||
"Transaction data validation failed. The reference number or the amount "
|
||||
"received from the gateway does not match the internal bank record."
|
||||
)
|
||||
raise AZBankGatewaysException(error_message)
|
||||
self._set_pay_gate_tran_id(transaction_data)
|
||||
|
||||
def _settle_transaction(self):
|
||||
try:
|
||||
data = {
|
||||
"merchantConfigurationId": self._merchant_configuration_id,
|
||||
"payGateTranId": self._get_pay_gate_tran_id(),
|
||||
}
|
||||
self._send_request(self._settlement_api_url, data=data, is_json=False)
|
||||
except Exception:
|
||||
logging.debug("AsanPardakht gateway did not settle the payment")
|
||||
|
||||
def _set_pay_gate_tran_id(self, transaction_data):
|
||||
self._bank.extra_information = json.dumps({'payGateTranID': transaction_data.get('payGateTranID')})
|
||||
self._bank.save(update_fields={'extra_information'})
|
||||
|
||||
def _get_pay_gate_tran_id(self):
|
||||
return json.loads(self._bank.extra_information)['payGateTranID']
|
||||
@@ -58,6 +58,7 @@ class Bahamta(BaseBank):
|
||||
"payer_mobile": self.get_mobile_number(),
|
||||
"callback_url": self._get_gateway_callback_url(),
|
||||
}
|
||||
data.update(self.get_custom_data())
|
||||
return data
|
||||
|
||||
def prepare_pay(self):
|
||||
@@ -70,7 +71,9 @@ class Bahamta(BaseBank):
|
||||
if response_json["ok"]:
|
||||
# در این سیستم رفرنس برای ذخیره سازی بر نمی گردد!
|
||||
token = self.get_tracking_code()
|
||||
self._payment_url, self._params = split_to_dict_querystring(response_json["result"]["payment_url"])
|
||||
self._payment_url, self._params = split_to_dict_querystring(
|
||||
response_json["result"]["payment_url"]
|
||||
)
|
||||
self._set_reference_number(token)
|
||||
else:
|
||||
logging.critical("Bahamta gateway reject payment")
|
||||
@@ -82,7 +85,7 @@ class Bahamta(BaseBank):
|
||||
|
||||
def prepare_verify_from_gateway(self):
|
||||
super(Bahamta, self).prepare_verify_from_gateway()
|
||||
token = self.get_request().GET.get("reference", None)
|
||||
token = self.get_request().GET.get("reference")
|
||||
self._set_reference_number(token)
|
||||
self._set_bank_record()
|
||||
|
||||
@@ -109,7 +112,7 @@ class Bahamta(BaseBank):
|
||||
super(Bahamta, self).verify(transaction_code)
|
||||
data = self.get_verify_data()
|
||||
response_json = self._send_data(self._verify_api_url, data)
|
||||
if response_json.get("ok", False) and response_json.get("result", {}).get("state", None) == "paid":
|
||||
if response_json.get("ok", False) and response_json.get("result", {}).get("state") == "paid":
|
||||
self._set_payment_status(PaymentStatus.COMPLETE)
|
||||
extra_information = json.dumps(response_json.get("result", {}))
|
||||
self._bank.extra_information = extra_information
|
||||
@@ -121,7 +124,7 @@ class Bahamta(BaseBank):
|
||||
def _send_data(self, api, data):
|
||||
try:
|
||||
url = append_querystring(api, data)
|
||||
response = requests.get(url, timeout=5)
|
||||
response = requests.get(url, timeout=self.get_timeout())
|
||||
except requests.Timeout:
|
||||
logging.exception("Bahamta time out gateway {}".format(data))
|
||||
raise BankGatewayConnectionError()
|
||||
|
||||
@@ -4,11 +4,14 @@ import uuid
|
||||
from urllib import parse
|
||||
|
||||
import six
|
||||
from django.conf import settings as django_settings
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from azbankgateways.utils import append_querystring, build_full_url
|
||||
|
||||
from .. import default_settings as settings
|
||||
from ..exceptions import (
|
||||
AmountDoesNotSupport,
|
||||
@@ -18,7 +21,6 @@ from ..exceptions import (
|
||||
SafeSettingsEnabled,
|
||||
)
|
||||
from ..models import Bank, CurrencyEnum, PaymentStatus
|
||||
from ..utils import append_querystring
|
||||
|
||||
|
||||
# TODO: handle and expire record after 15 minutes
|
||||
@@ -41,8 +43,12 @@ class BaseBank:
|
||||
def __init__(self, identifier: str, **kwargs):
|
||||
self.identifier = identifier
|
||||
self.default_setting_kwargs = kwargs
|
||||
self._custom_data: dict = {}
|
||||
self.set_default_settings()
|
||||
|
||||
def _is_strict_origin_policy_enabled(self):
|
||||
return django_settings.SECURE_REFERRER_POLICY == 'strict-origin-when-cross-origin'
|
||||
|
||||
@abc.abstractmethod
|
||||
def set_default_settings(self):
|
||||
"""default setting, like fetch merchant code, terminal id and etc"""
|
||||
@@ -162,6 +168,13 @@ class BaseBank:
|
||||
def get_mobile_number(self):
|
||||
return self._mobile_number
|
||||
|
||||
def set_custom_data(self, data: dict):
|
||||
"""تنظیم قابلیت های سفارشی برای درگاه"""
|
||||
self._custom_data = data
|
||||
|
||||
def get_custom_data(self):
|
||||
return self._custom_data
|
||||
|
||||
def set_client_callback_url(self, callback_url):
|
||||
"""ذخیره کال بک از طریق نرم افزار برای بازگردانی کاربر پس از بازگشت درگاه بانک به پکیج و سپس از پکیج به نرم
|
||||
افزار."""
|
||||
@@ -187,7 +200,10 @@ class BaseBank:
|
||||
def _set_bank_record(self):
|
||||
try:
|
||||
self._bank = Bank.objects.get(
|
||||
Q(Q(reference_number=self.get_reference_number()) | Q(tracking_code=self.get_tracking_code())),
|
||||
Q(
|
||||
Q(reference_number=self.get_reference_number())
|
||||
| Q(tracking_code=self.get_tracking_code())
|
||||
),
|
||||
Q(bank_type=self.get_bank_type()),
|
||||
)
|
||||
logging.debug("Set reference find bank object.")
|
||||
@@ -216,7 +232,10 @@ class BaseBank:
|
||||
return self._transaction_status_text
|
||||
|
||||
def _set_payment_status(self, payment_status):
|
||||
if payment_status == PaymentStatus.RETURN_FROM_BANK and self._bank.status != PaymentStatus.REDIRECT_TO_BANK:
|
||||
if (
|
||||
payment_status == PaymentStatus.RETURN_FROM_BANK
|
||||
and self._bank.status != PaymentStatus.REDIRECT_TO_BANK
|
||||
):
|
||||
logging.debug(
|
||||
"Payment status is not status suitable.",
|
||||
extra={"status": self._bank.status},
|
||||
@@ -247,6 +266,10 @@ class BaseBank:
|
||||
def get_currency(self):
|
||||
return self._currency
|
||||
|
||||
@staticmethod
|
||||
def get_timeout():
|
||||
return settings.BANK_TIMEOUT
|
||||
|
||||
def get_gateway_amount(self):
|
||||
return self._gateway_amount
|
||||
|
||||
@@ -357,8 +380,8 @@ class BaseBank:
|
||||
return redirect_url
|
||||
|
||||
def _get_gateway_callback_url(self):
|
||||
url = reverse(settings.CALLBACK_NAMESPACE)
|
||||
if self.get_request():
|
||||
url = reverse(settings.CALLBACK_NAMESPACE)
|
||||
url_parts = list(parse.urlparse(url))
|
||||
if not (url_parts[0] and url_parts[1]):
|
||||
url = self.get_request().build_absolute_uri(url)
|
||||
@@ -366,5 +389,6 @@ class BaseBank:
|
||||
query.update({"bank_type": self.get_bank_type()})
|
||||
query.update({"identifier": self.identifier})
|
||||
url = append_querystring(url, query)
|
||||
|
||||
else:
|
||||
url = build_full_url(settings.CALLBACK_NAMESPACE)
|
||||
return url
|
||||
|
||||
@@ -22,6 +22,12 @@ class BMI(BaseBank):
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super(BMI, self).__init__(**kwargs)
|
||||
if not self._is_strict_origin_policy_enabled():
|
||||
raise SettingDoesNotExist(
|
||||
"SECURE_REFERRER_POLICY is not set to 'strict-origin-when-cross-origin'"
|
||||
" in django setting, it's mandatory for BMI gateway"
|
||||
)
|
||||
|
||||
self.set_gateway_currency(CurrencyEnum.IRR)
|
||||
self._token_api_url = "https://sadad.shaparak.ir/vpg/api/v0/Request/PaymentRequest"
|
||||
self._payment_url = "https://sadad.shaparak.ir/VPG/Purchase"
|
||||
@@ -54,6 +60,7 @@ class BMI(BaseBank):
|
||||
"OrderId": self.get_tracking_code(),
|
||||
"AdditionalData": "oi:%s-ou:%s" % (self.get_tracking_code(), self.get_mobile_number()),
|
||||
}
|
||||
data.update(self.get_custom_data())
|
||||
return data
|
||||
|
||||
def prepare_pay(self):
|
||||
@@ -63,7 +70,7 @@ class BMI(BaseBank):
|
||||
super(BMI, self).pay()
|
||||
data = self.get_pay_data()
|
||||
response_json = self._send_data(self._token_api_url, data)
|
||||
if response_json["ResCode"] == "0":
|
||||
if str(response_json["ResCode"]) == "0":
|
||||
token = response_json["Token"]
|
||||
self._set_reference_number(token)
|
||||
else:
|
||||
@@ -99,10 +106,11 @@ class BMI(BaseBank):
|
||||
super(BMI, self).verify(transaction_code)
|
||||
data = self.get_verify_data()
|
||||
response_json = self._send_data(self._verify_api_url, data)
|
||||
if response_json["ResCode"] == "0":
|
||||
if str(response_json["ResCode"]) == "0":
|
||||
self._set_payment_status(PaymentStatus.COMPLETE)
|
||||
extra_information = (
|
||||
f"RetrivalRefNo={response_json['RetrivalRefNo']},SystemTraceNo={response_json['SystemTraceNo']}"
|
||||
f"RetrivalRefNo={response_json['RetrivalRefNo']}"
|
||||
",SystemTraceNo={response_json['SystemTraceNo']}"
|
||||
)
|
||||
self._bank.extra_information = extra_information
|
||||
self._bank.save()
|
||||
@@ -113,10 +121,13 @@ class BMI(BaseBank):
|
||||
def prepare_verify_from_gateway(self):
|
||||
super(BMI, self).prepare_verify_from_gateway()
|
||||
request = self.get_request()
|
||||
for method in ["POST", "GET", "data", "PUT"]:
|
||||
token = getattr(request, method, {}).get("token", None)
|
||||
if token:
|
||||
method_data = getattr(request, "POST", {})
|
||||
token = None
|
||||
for key, value in method_data.items():
|
||||
if key.lower() == "token":
|
||||
token = value
|
||||
break
|
||||
|
||||
if not token:
|
||||
raise BankGatewayStateInvalid
|
||||
self._set_reference_number(token)
|
||||
@@ -142,7 +153,7 @@ class BMI(BaseBank):
|
||||
|
||||
def _send_data(self, api, data):
|
||||
try:
|
||||
response = requests.post(api, json=data, timeout=5)
|
||||
response = requests.post(api, json=data, timeout=self.get_timeout())
|
||||
except requests.Timeout:
|
||||
logging.exception("BMI time out gateway {}".format(data))
|
||||
raise BankGatewayConnectionError()
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
from azbankgateways.banks import BaseBank
|
||||
from azbankgateways.exceptions import BankGatewayConnectionError, SettingDoesNotExist
|
||||
from azbankgateways.exceptions.exceptions import BankGatewayRejectPayment
|
||||
from azbankgateways.models import BankType, CurrencyEnum, PaymentStatus
|
||||
from azbankgateways.utils import get_json, split_to_dict_querystring
|
||||
|
||||
|
||||
class IDPay(BaseBank):
|
||||
_merchant_code = None
|
||||
_method = None
|
||||
_x_sandbox = None
|
||||
_payment_url = None
|
||||
_params = {}
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super(IDPay, self).__init__(**kwargs)
|
||||
self.set_gateway_currency(CurrencyEnum.IRR)
|
||||
self._token_api_url = "https://api.idpay.ir/v1.1/payment"
|
||||
self._verify_api_url = "https://api.idpay.ir/v1.1/payment/verify"
|
||||
|
||||
def get_bank_type(self):
|
||||
return BankType.IDPAY
|
||||
|
||||
def set_default_settings(self):
|
||||
for item in ["MERCHANT_CODE", "METHOD", "X_SANDBOX"]:
|
||||
if item not in self.default_setting_kwargs:
|
||||
raise SettingDoesNotExist()
|
||||
setattr(self, f"_{item.lower()}", self.default_setting_kwargs[item])
|
||||
|
||||
self._x_sandbox = str(self._x_sandbox)
|
||||
|
||||
"""
|
||||
gateway
|
||||
"""
|
||||
|
||||
def _get_gateway_payment_url_parameter(self):
|
||||
return self._payment_url
|
||||
|
||||
def _get_gateway_payment_parameter(self):
|
||||
params = {}
|
||||
params.update(self._params)
|
||||
return params
|
||||
|
||||
def _get_gateway_payment_method_parameter(self):
|
||||
return "GET"
|
||||
|
||||
"""
|
||||
pay
|
||||
"""
|
||||
|
||||
def get_pay_data(self):
|
||||
data = {
|
||||
"order_id": self.get_tracking_code(),
|
||||
"amount": self.get_gateway_amount(),
|
||||
"phone": self.get_mobile_number(),
|
||||
"callback": self._get_gateway_callback_url(),
|
||||
}
|
||||
return data
|
||||
|
||||
def prepare_pay(self):
|
||||
super(IDPay, self).prepare_pay()
|
||||
|
||||
def pay(self):
|
||||
super(IDPay, self).pay()
|
||||
data = self.get_pay_data()
|
||||
response_json = self._send_data(self._token_api_url, data)
|
||||
if "id" in response_json and "link" in response_json and response_json["link"] and response_json["id"]:
|
||||
token = response_json["id"]
|
||||
self._payment_url, self._params = split_to_dict_querystring(response_json["link"])
|
||||
self._set_reference_number(token)
|
||||
else:
|
||||
logging.critical("IDPay gateway reject payment")
|
||||
raise BankGatewayRejectPayment(self.get_transaction_status_text())
|
||||
|
||||
"""
|
||||
verify gateway
|
||||
"""
|
||||
|
||||
def prepare_verify_from_gateway(self):
|
||||
super(IDPay, self).prepare_verify_from_gateway()
|
||||
for method in ["GET", "POST", "data"]:
|
||||
token = getattr(self.get_request(), method).get("id", None)
|
||||
if token:
|
||||
self._set_reference_number(token)
|
||||
self._set_bank_record()
|
||||
break
|
||||
|
||||
def verify_from_gateway(self, request):
|
||||
super(IDPay, self).verify_from_gateway(request)
|
||||
|
||||
"""
|
||||
verify
|
||||
"""
|
||||
|
||||
def get_verify_data(self):
|
||||
super(IDPay, self).get_verify_data()
|
||||
data = {
|
||||
"id": self.get_reference_number(),
|
||||
"order_id": self.get_tracking_code(),
|
||||
}
|
||||
return data
|
||||
|
||||
def prepare_verify(self, tracking_code):
|
||||
super(IDPay, self).prepare_verify(tracking_code)
|
||||
|
||||
def verify(self, transaction_code):
|
||||
super(IDPay, self).verify(transaction_code)
|
||||
data = self.get_verify_data()
|
||||
response_json = self._send_data(self._verify_api_url, data, timeout=10)
|
||||
if response_json.get("verify", {}).get("date", None):
|
||||
self._set_payment_status(PaymentStatus.COMPLETE)
|
||||
extra_information = json.dumps(response_json)
|
||||
self._bank.extra_information = extra_information
|
||||
self._bank.save()
|
||||
else:
|
||||
self._set_payment_status(PaymentStatus.CANCEL_BY_USER)
|
||||
logging.debug("IDPay gateway unapprove payment")
|
||||
|
||||
def _send_data(self, api, data, timeout=5):
|
||||
headers = {
|
||||
"X-API-KEY": self._merchant_code,
|
||||
"X-SANDBOX": self._x_sandbox,
|
||||
}
|
||||
try:
|
||||
response = requests.post(api, headers=headers, json=data, timeout=timeout)
|
||||
except requests.Timeout:
|
||||
logging.exception("IDPay time out gateway {}".format(data))
|
||||
raise BankGatewayConnectionError()
|
||||
except requests.ConnectionError:
|
||||
logging.exception("IDPay time out gateway {}".format(data))
|
||||
raise BankGatewayConnectionError()
|
||||
|
||||
response_json = get_json(response)
|
||||
if "error_message" in response_json:
|
||||
self._set_transaction_status_text(response_json["error_message"])
|
||||
return response_json
|
||||
@@ -0,0 +1,115 @@
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
from azbankgateways.banks import BaseBank
|
||||
from azbankgateways.exceptions import SettingDoesNotExist
|
||||
from azbankgateways.exceptions.exceptions import (
|
||||
BankGatewayConnectionError,
|
||||
BankGatewayRejectPayment,
|
||||
)
|
||||
from azbankgateways.models import BankType, CurrencyEnum, PaymentStatus
|
||||
from azbankgateways.utils import get_json
|
||||
|
||||
|
||||
class IranDargah(BaseBank):
|
||||
_merchant_code = None
|
||||
_sandbox = False
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
kwargs.setdefault("SANDBOX", 0)
|
||||
super().__init__(**kwargs)
|
||||
self.set_gateway_currency(CurrencyEnum.IRR)
|
||||
self._sandbox = kwargs.get("SANDBOX", 0) == 1
|
||||
|
||||
base_url = "https://dargaah.ir"
|
||||
if self._sandbox:
|
||||
base_url += "/sandbox"
|
||||
|
||||
self._payment_url = f"{base_url}/payment"
|
||||
self._startpay_url = f"{base_url}/ird/startpay/"
|
||||
self._verify_url = f"{base_url}/verification"
|
||||
|
||||
def get_bank_type(self):
|
||||
return BankType.IRANDARGAH
|
||||
|
||||
def set_default_settings(self):
|
||||
for item in ["MERCHANT_CODE"]:
|
||||
if item not in self.default_setting_kwargs:
|
||||
raise SettingDoesNotExist(f"{item} not in settings")
|
||||
setattr(self, f"_{item.lower()}", self.default_setting_kwargs[item])
|
||||
|
||||
def _get_gateway_payment_url_parameter(self):
|
||||
return f"{self._startpay_url}{self.get_reference_number()}"
|
||||
|
||||
def _get_gateway_payment_parameter(self):
|
||||
return {}
|
||||
|
||||
def _get_gateway_payment_method_parameter(self):
|
||||
return "GET"
|
||||
|
||||
def get_pay_data(self):
|
||||
return {
|
||||
"merchantID": self._merchant_code,
|
||||
"amount": self.get_gateway_amount(),
|
||||
"callbackURL": self._get_gateway_callback_url(),
|
||||
"orderId": str(self.get_tracking_code()),
|
||||
**self.get_custom_data(),
|
||||
}
|
||||
|
||||
def prepare_pay(self):
|
||||
super().prepare_pay()
|
||||
|
||||
def pay(self):
|
||||
super().pay()
|
||||
data = self.get_pay_data()
|
||||
result = self._send_data(api=self._payment_url, data=data)
|
||||
if result["status"] == 200:
|
||||
self._set_reference_number(result["authority"])
|
||||
else:
|
||||
logging.critical("IranDargah reject payment: %s", result.get("message"))
|
||||
raise BankGatewayRejectPayment(self.get_transaction_status_text())
|
||||
|
||||
def prepare_verify_from_gateway(self):
|
||||
super().prepare_verify_from_gateway()
|
||||
authority = self.get_request().POST.get("authority")
|
||||
self._set_reference_number(authority)
|
||||
self._set_bank_record()
|
||||
|
||||
def verify_from_gateway(self, request):
|
||||
super().verify_from_gateway(request)
|
||||
|
||||
def get_verify_data(self):
|
||||
return {
|
||||
"merchantID": self._merchant_code,
|
||||
"authority": self.get_reference_number(),
|
||||
"amount": self.get_gateway_amount(),
|
||||
"orderId": str(self.get_tracking_code()),
|
||||
}
|
||||
|
||||
def prepare_verify(self, tracking_code):
|
||||
super().prepare_verify(tracking_code)
|
||||
|
||||
def verify(self, transaction_code):
|
||||
super().verify(transaction_code)
|
||||
data = self.get_verify_data()
|
||||
result = self._send_data(api=self._verify_url, data=data)
|
||||
|
||||
if result.get("status") in [100, 101]:
|
||||
self._set_payment_status(PaymentStatus.COMPLETE)
|
||||
else:
|
||||
self._set_payment_status(PaymentStatus.CANCEL_BY_USER)
|
||||
logging.debug("IranDargah verify failed: %s", result.get("message"))
|
||||
|
||||
def _send_data(self, api, data):
|
||||
try:
|
||||
response = requests.post(api, json=data, timeout=self.get_timeout())
|
||||
response.raise_for_status()
|
||||
except requests.RequestException as e:
|
||||
logging.exception("IranDargah connection error: %s", e)
|
||||
raise BankGatewayConnectionError()
|
||||
|
||||
result = get_json(response)
|
||||
msg = result.get("message", "no message")
|
||||
self._set_transaction_status_text(msg)
|
||||
return result
|
||||
@@ -68,6 +68,7 @@ class Mellat(BaseBank):
|
||||
"callBackUrl": self._get_gateway_callback_url(),
|
||||
"payerId": 0,
|
||||
}
|
||||
data.update(self.get_custom_data())
|
||||
return data
|
||||
|
||||
def prepare_pay(self):
|
||||
@@ -163,9 +164,7 @@ class Mellat(BaseBank):
|
||||
status_text = "Payment ID is incorrect"
|
||||
elif response == "414":
|
||||
status_text = "The organization issuing the bill is invalid"
|
||||
elif response == "415":
|
||||
status_text = "The working session has ended"
|
||||
elif response == "416":
|
||||
elif response in ["415", "416"]:
|
||||
status_text = "The working session has ended"
|
||||
elif response == "417":
|
||||
status_text = "Payer ID is invalid"
|
||||
@@ -187,12 +186,12 @@ class Mellat(BaseBank):
|
||||
def prepare_verify_from_gateway(self):
|
||||
super(Mellat, self).prepare_verify_from_gateway()
|
||||
post = self.get_request().POST
|
||||
token = post.get("RefId", None)
|
||||
token = post.get("RefId")
|
||||
if not token:
|
||||
return
|
||||
self._set_reference_number(token)
|
||||
self._set_bank_record()
|
||||
self._bank.extra_information = dumps(dict(zip(post.keys(), post.values())))
|
||||
self._bank.extra_information = dumps(dict(post.items()))
|
||||
self._bank.save()
|
||||
|
||||
def verify_from_gateway(self, request):
|
||||
@@ -250,7 +249,7 @@ class Mellat(BaseBank):
|
||||
|
||||
@staticmethod
|
||||
def _get_client():
|
||||
transport = Transport(timeout=5, operation_timeout=5)
|
||||
transport = Transport(timeout=Mellat.get_timeout(), operation_timeout=Mellat.get_timeout())
|
||||
client = Client("https://bpm.shaparak.ir/pgwchannel/services/pgw?wsdl", transport=transport)
|
||||
return client
|
||||
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
from azbankgateways.banks import BaseBank
|
||||
from azbankgateways.default_settings import TRACKING_CODE_QUERY_PARAM
|
||||
from azbankgateways.exceptions import BankGatewayConnectionError, SettingDoesNotExist
|
||||
from azbankgateways.exceptions.exceptions import (
|
||||
BankGatewayRejectPayment,
|
||||
BankGatewayStateInvalid,
|
||||
)
|
||||
from azbankgateways.models import BankType, CurrencyEnum, PaymentStatus
|
||||
|
||||
|
||||
class PayV1(BaseBank):
|
||||
_merchant_code = None
|
||||
_x_sandbox = None
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super(PayV1, self).__init__(**kwargs)
|
||||
self.set_gateway_currency(CurrencyEnum.IRR)
|
||||
self._token_api_url = "https://pay.ir/pg/send"
|
||||
self._payment_url = "https://pay.ir/pg/{}"
|
||||
self._verify_api_url = "https://pay.ir/pg/verify"
|
||||
|
||||
def get_bank_type(self):
|
||||
return BankType.PAYV1
|
||||
|
||||
def set_default_settings(self):
|
||||
for item in ["MERCHANT_CODE", "X_SANDBOX"]:
|
||||
if item not in self.default_setting_kwargs:
|
||||
raise SettingDoesNotExist()
|
||||
setattr(self, f"_{item.lower()}", self.default_setting_kwargs[item])
|
||||
|
||||
self._merchant_code = self._merchant_code if not self._x_sandbox else "test"
|
||||
|
||||
"""
|
||||
gateway
|
||||
"""
|
||||
|
||||
def _get_gateway_payment_url_parameter(self):
|
||||
return self._payment_url.format(self._reference_number)
|
||||
|
||||
def _get_gateway_payment_parameter(self):
|
||||
return {}
|
||||
|
||||
def _get_gateway_payment_method_parameter(self):
|
||||
return "GET"
|
||||
|
||||
"""
|
||||
pay
|
||||
"""
|
||||
|
||||
def get_pay_data(self):
|
||||
data = {
|
||||
"api": self._merchant_code,
|
||||
"amount": self.get_gateway_amount(),
|
||||
"redirect": self._get_gateway_callback_url(),
|
||||
"mobile": self.get_mobile_number(),
|
||||
"factorNumber": self.get_tracking_code(),
|
||||
}
|
||||
return data
|
||||
|
||||
def prepare_pay(self):
|
||||
super(PayV1, self).prepare_pay()
|
||||
|
||||
def pay(self):
|
||||
super(PayV1, self).pay()
|
||||
data = self.get_pay_data()
|
||||
response = self._send_data(self._token_api_url, data)
|
||||
response_json = response.json()
|
||||
if response.status_code == 200 and int(response_json["status"]) == 1:
|
||||
token = response_json["token"]
|
||||
self._set_reference_number(token)
|
||||
else:
|
||||
logging.critical(
|
||||
"PayV1 gateway reject payment with error code {0} and status code {1}".format(
|
||||
response_json["errorCode"], response.status_code
|
||||
)
|
||||
)
|
||||
raise BankGatewayRejectPayment(self.get_transaction_status_text())
|
||||
|
||||
"""
|
||||
verify gateway
|
||||
"""
|
||||
|
||||
def prepare_verify_from_gateway(self):
|
||||
super(PayV1, self).prepare_verify_from_gateway()
|
||||
for method in ["GET", "POST", "data"]:
|
||||
token = getattr(self.get_request(), method).get(TRACKING_CODE_QUERY_PARAM, None)
|
||||
if token:
|
||||
self._set_reference_number(token)
|
||||
self._set_bank_record()
|
||||
break
|
||||
else:
|
||||
raise BankGatewayStateInvalid
|
||||
|
||||
def verify_from_gateway(self, request):
|
||||
super(PayV1, self).verify_from_gateway(request)
|
||||
|
||||
"""
|
||||
verify
|
||||
"""
|
||||
|
||||
def get_verify_data(self):
|
||||
super(PayV1, self).get_verify_data()
|
||||
data = {
|
||||
"api": self._merchant_code(),
|
||||
"token": self.get_reference_number(),
|
||||
}
|
||||
return data
|
||||
|
||||
def prepare_verify(self, tracking_code):
|
||||
super(PayV1, self).prepare_verify(tracking_code)
|
||||
|
||||
def verify(self, tracking_code):
|
||||
super(PayV1, self).verify(tracking_code)
|
||||
|
||||
data = self.get_verify_data()
|
||||
response = self._send_data(self._verify_api_url, data, timeout=10)
|
||||
response_json = response.json()
|
||||
status = PaymentStatus.COMPLETE
|
||||
if int(response_json["status"]) != 1:
|
||||
if int(response_json["errorCode"]) == -5:
|
||||
status = PaymentStatus.ERROR
|
||||
elif int(response_json["errorCode"]) == -9:
|
||||
status = PaymentStatus.EXPIRE_VERIFY_PAYMENT
|
||||
elif int(response_json["errorCode"]) == -15:
|
||||
status = PaymentStatus.CANCEL_BY_USER
|
||||
elif int(response_json["errorCode"]) == -27:
|
||||
status = PaymentStatus.RETURN_FROM_BANK
|
||||
else:
|
||||
status = PaymentStatus.ERROR
|
||||
|
||||
self._set_payment_status(status)
|
||||
extra_information = json.dumps(response_json)
|
||||
self._bank.extra_information = extra_information
|
||||
self._bank.save()
|
||||
|
||||
def _send_data(self, url, data, timeout=5) -> requests.post:
|
||||
try:
|
||||
logging.debug("Sending POST request to {} with data {}".format(url, data))
|
||||
response = requests.post(url, json=data, timeout=timeout)
|
||||
except requests.Timeout:
|
||||
logging.exception("PayV1 time out gateway {}".format(data))
|
||||
raise BankGatewayConnectionError()
|
||||
except requests.ConnectionError:
|
||||
logging.exception("PayV1 time out gateway {}".format(data))
|
||||
raise BankGatewayConnectionError()
|
||||
|
||||
return response
|
||||
@@ -1,7 +1,6 @@
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from zeep import Client, Transport
|
||||
|
||||
from azbankgateways.banks import BaseBank
|
||||
from azbankgateways.exceptions import BankGatewayConnectionError, SettingDoesNotExist
|
||||
@@ -16,10 +15,16 @@ class SEP(BaseBank):
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super(SEP, self).__init__(**kwargs)
|
||||
if not self._is_strict_origin_policy_enabled():
|
||||
raise SettingDoesNotExist(
|
||||
"SECURE_REFERRER_POLICY is not set to 'strict-origin-when-cross-origin' in django setting,"
|
||||
" it's mandatory for Saman gateway"
|
||||
)
|
||||
|
||||
self.set_gateway_currency(CurrencyEnum.IRR)
|
||||
self._token_api_url = "https://sep.shaparak.ir/MobilePG/MobilePayment"
|
||||
self._token_api_url = "https://sep.shaparak.ir/onlinepg/onlinepg"
|
||||
self._payment_url = "https://sep.shaparak.ir/OnlinePG/OnlinePG"
|
||||
self._verify_api_url = "https://verify.sep.ir/Payments/ReferencePayment.asmx?WSDL"
|
||||
self._verify_api_url = "https://sep.shaparak.ir/verifyTxnRandomSessionkey/ipg/VerifyTransaction"
|
||||
|
||||
def get_bank_type(self):
|
||||
return BankType.SEP
|
||||
@@ -32,14 +37,14 @@ class SEP(BaseBank):
|
||||
|
||||
def get_pay_data(self):
|
||||
data = {
|
||||
"Action": "Token",
|
||||
"action": "Token",
|
||||
"Amount": self.get_gateway_amount(),
|
||||
"Wage": 0,
|
||||
"TerminalId": self._merchant_code,
|
||||
"ResNum": self.get_tracking_code(),
|
||||
"RedirectURL": self._get_gateway_callback_url(),
|
||||
"CellNumber": self.get_mobile_number(),
|
||||
}
|
||||
data.update(self.get_custom_data())
|
||||
return data
|
||||
|
||||
def prepare_pay(self):
|
||||
@@ -80,15 +85,15 @@ class SEP(BaseBank):
|
||||
def prepare_verify_from_gateway(self):
|
||||
super(SEP, self).prepare_verify_from_gateway()
|
||||
request = self.get_request()
|
||||
tracking_code = request.GET.get("ResNum", None)
|
||||
token = request.GET.get("Token", None)
|
||||
tracking_code = request.GET.get("ResNum")
|
||||
token = request.GET.get("Token")
|
||||
self._set_tracking_code(tracking_code)
|
||||
self._set_bank_record()
|
||||
ref_num = request.GET.get("RefNum", None)
|
||||
ref_num = request.GET.get("RefNum")
|
||||
if request.GET.get("State", "NOK") == "OK" and ref_num:
|
||||
self._set_reference_number(ref_num)
|
||||
self._bank.reference_number = ref_num
|
||||
extra_information = f"TRACENO={request.GET.get('TRACENO', None)}, RefNum={ref_num}, Token={token}"
|
||||
extra_information = f"TRACENO={request.GET.get('TRACENO')}, RefNum={ref_num}, Token={token}"
|
||||
self._bank.extra_information = extra_information
|
||||
self._bank.save()
|
||||
|
||||
@@ -101,8 +106,7 @@ class SEP(BaseBank):
|
||||
|
||||
def get_verify_data(self):
|
||||
super(SEP, self).get_verify_data()
|
||||
data = self.get_reference_number(), self._merchant_code
|
||||
return data
|
||||
return {"RefNum": self.get_reference_number(), "TerminalNumber": self._merchant_code}
|
||||
|
||||
def prepare_verify(self, tracking_code):
|
||||
super(SEP, self).prepare_verify(tracking_code)
|
||||
@@ -110,9 +114,8 @@ class SEP(BaseBank):
|
||||
def verify(self, transaction_code):
|
||||
super(SEP, self).verify(transaction_code)
|
||||
data = self.get_verify_data()
|
||||
client = self._get_client(self._verify_api_url)
|
||||
result = client.service.verifyTransaction(*data)
|
||||
if result == self.get_gateway_amount():
|
||||
result = self._send_data(api=self._verify_api_url, data=data)
|
||||
if result.get('ResultCode') == 0:
|
||||
self._set_payment_status(PaymentStatus.COMPLETE)
|
||||
else:
|
||||
self._set_payment_status(PaymentStatus.CANCEL_BY_USER)
|
||||
@@ -120,7 +123,7 @@ class SEP(BaseBank):
|
||||
|
||||
def _send_data(self, api, data):
|
||||
try:
|
||||
response = requests.post(api, json=data, timeout=5)
|
||||
response = requests.post(api, json=data, timeout=self.get_timeout())
|
||||
except requests.Timeout:
|
||||
logging.exception("SEP time out gateway {}".format(data))
|
||||
raise BankGatewayConnectionError()
|
||||
@@ -131,16 +134,3 @@ class SEP(BaseBank):
|
||||
response_json = get_json(response)
|
||||
self._set_transaction_status_text(response_json.get("errorDesc"))
|
||||
return response_json
|
||||
|
||||
@staticmethod
|
||||
def _get_client(url):
|
||||
headers = {
|
||||
"Accept": "*/*",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"Connection": "keep-alive",
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 6.1; WOW64; rv:12.0) Gecko/20100101 Firefox/12.0",
|
||||
}
|
||||
transport = Transport(timeout=5, operation_timeout=5)
|
||||
transport.session.headers = headers
|
||||
client = Client(url, transport=transport)
|
||||
return client
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import logging
|
||||
|
||||
from zeep import Client, Transport
|
||||
import requests
|
||||
|
||||
from azbankgateways.banks import BaseBank
|
||||
from azbankgateways.exceptions import SettingDoesNotExist
|
||||
from azbankgateways.exceptions.exceptions import BankGatewayRejectPayment
|
||||
from azbankgateways.exceptions.exceptions import (
|
||||
BankGatewayConnectionError,
|
||||
BankGatewayRejectPayment,
|
||||
)
|
||||
from azbankgateways.models import BankType, CurrencyEnum, PaymentStatus
|
||||
from azbankgateways.utils import get_json
|
||||
|
||||
|
||||
class Zarinpal(BaseBank):
|
||||
@@ -16,8 +20,12 @@ class Zarinpal(BaseBank):
|
||||
kwargs.setdefault("SANDBOX", 0)
|
||||
super(Zarinpal, self).__init__(**kwargs)
|
||||
self.set_gateway_currency(CurrencyEnum.IRT)
|
||||
self._payment_url = "https://www.zarinpal.com/pg/StartPay/{}/ZarinGate"
|
||||
self._sandbox_url = "https://sandbox.zarinpal.com/pg/StartPay/{}/ZarinGate"
|
||||
self._payment_type = 'payment'
|
||||
if self._sandbox:
|
||||
self._payment_type = 'sandbox'
|
||||
self._payment_url = f"https://{self._payment_type}.zarinpal.com/pg/v4/payment/request.json"
|
||||
self._startpay_url = f"https://{self._payment_type}.zarinpal.com/pg/StartPay/"
|
||||
self._verify_url = f"https://{self._payment_type}.zarinpal.com/pg/v4/payment/verify.json"
|
||||
|
||||
def get_bank_type(self):
|
||||
return BankType.ZARINPAL
|
||||
@@ -37,9 +45,7 @@ class Zarinpal(BaseBank):
|
||||
return 1000
|
||||
|
||||
def _get_gateway_payment_url_parameter(self):
|
||||
if self._sandbox:
|
||||
return self._sandbox_url.format(self.get_reference_number())
|
||||
return self._payment_url.format(self.get_reference_number())
|
||||
return self._startpay_url + "{}".format(self.get_reference_number())
|
||||
|
||||
def _get_gateway_payment_parameter(self):
|
||||
return {}
|
||||
@@ -54,14 +60,19 @@ class Zarinpal(BaseBank):
|
||||
def get_pay_data(self):
|
||||
description = "خرید با شماره پیگیری - {}".format(self.get_tracking_code())
|
||||
|
||||
return {
|
||||
"Description": description,
|
||||
"MerchantID": self._merchant_code,
|
||||
"Amount": self.get_gateway_amount(),
|
||||
"Email": None,
|
||||
"Mobile": self.get_mobile_number(),
|
||||
"CallbackURL": self._get_gateway_callback_url(),
|
||||
data = {
|
||||
"description": description,
|
||||
"merchant_id": self._merchant_code,
|
||||
"amount": self.get_gateway_amount(),
|
||||
"currency": self.get_gateway_currency(),
|
||||
"metadata": {},
|
||||
"callback_url": self._get_gateway_callback_url(),
|
||||
}
|
||||
mobile_number = self.get_mobile_number()
|
||||
if mobile_number:
|
||||
data["metadata"].update({"mobile": mobile_number})
|
||||
data.update(self.get_custom_data())
|
||||
return data
|
||||
|
||||
def prepare_pay(self):
|
||||
super(Zarinpal, self).prepare_pay()
|
||||
@@ -69,10 +80,9 @@ class Zarinpal(BaseBank):
|
||||
def pay(self):
|
||||
super(Zarinpal, self).pay()
|
||||
data = self.get_pay_data()
|
||||
client = self._get_client()
|
||||
result = client.service.PaymentRequest(**data)
|
||||
if result.Status == 100:
|
||||
token = result.Authority
|
||||
result = self._send_data(api=self._payment_url, data=data)
|
||||
if result['data']:
|
||||
token = result['data']['authority']
|
||||
self._set_reference_number(token)
|
||||
else:
|
||||
logging.critical("Zarinpal gateway reject payment")
|
||||
@@ -84,7 +94,7 @@ class Zarinpal(BaseBank):
|
||||
|
||||
def prepare_verify_from_gateway(self):
|
||||
super(Zarinpal, self).prepare_verify_from_gateway()
|
||||
token = self.get_request().GET.get("Authority", None)
|
||||
token = self.get_request().GET.get("Authority")
|
||||
self._set_reference_number(token)
|
||||
self._set_bank_record()
|
||||
|
||||
@@ -98,9 +108,9 @@ class Zarinpal(BaseBank):
|
||||
def get_verify_data(self):
|
||||
super(Zarinpal, self).get_verify_data()
|
||||
return {
|
||||
"MerchantID": self._merchant_code,
|
||||
"Authority": self.get_reference_number(),
|
||||
"Amount": self.get_gateway_amount(),
|
||||
"merchant_id": self._merchant_code,
|
||||
"authority": self.get_reference_number(),
|
||||
"amount": self.get_gateway_amount(),
|
||||
}
|
||||
|
||||
def prepare_verify(self, tracking_code):
|
||||
@@ -109,27 +119,26 @@ class Zarinpal(BaseBank):
|
||||
def verify(self, transaction_code):
|
||||
super(Zarinpal, self).verify(transaction_code)
|
||||
data = self.get_verify_data()
|
||||
client = self._get_client(timeout=10)
|
||||
try:
|
||||
result = client.service.PaymentVerification(**data)
|
||||
if result.Status in [100, 101]:
|
||||
result = self._send_data(api=self._verify_url, data=data)
|
||||
if result['data'] and result['data']['code'] in [100, 101]:
|
||||
self._set_payment_status(PaymentStatus.COMPLETE)
|
||||
else:
|
||||
self._set_payment_status(PaymentStatus.CANCEL_BY_USER)
|
||||
logging.debug("Zarinpal gateway unapprove payment")
|
||||
except:
|
||||
self._set_payment_status(PaymentStatus.CANCEL_BY_USER)
|
||||
logging.debug("Zarinpal gateway unapprove payment")
|
||||
|
||||
def _get_client(self, timeout=5):
|
||||
transport = Transport(timeout=timeout, operation_timeout=timeout)
|
||||
if self._sandbox:
|
||||
return Client(
|
||||
"https://sandbox.zarinpal.com/pg/services/WebGate/wsdl",
|
||||
transport=transport,
|
||||
)
|
||||
def _send_data(self, api, data):
|
||||
try:
|
||||
response = requests.post(api, json=data, timeout=self.get_timeout())
|
||||
except requests.Timeout:
|
||||
logging.exception("ZARINPAL time out gateway {}".format(data))
|
||||
raise BankGatewayConnectionError()
|
||||
except requests.ConnectionError:
|
||||
logging.exception("ZARINPAL time out gateway {}".format(data))
|
||||
raise BankGatewayConnectionError()
|
||||
|
||||
return Client(
|
||||
"https://www.zarinpal.com/pg/services/WebGate/wsdl",
|
||||
transport=transport,
|
||||
)
|
||||
response_json = get_json(response)
|
||||
if response_json['data']:
|
||||
self._set_transaction_status_text(response_json['data']['message'])
|
||||
else:
|
||||
self._set_transaction_status_text(response_json['errors']['message'])
|
||||
return response_json
|
||||
|
||||
@@ -55,6 +55,7 @@ class Zibal(BaseBank):
|
||||
"orderId": self.get_tracking_code(),
|
||||
"mobile": self.get_mobile_number(),
|
||||
}
|
||||
data.update(self.get_custom_data())
|
||||
return data
|
||||
|
||||
def prepare_pay(self):
|
||||
@@ -77,7 +78,7 @@ class Zibal(BaseBank):
|
||||
|
||||
def prepare_verify_from_gateway(self):
|
||||
super(Zibal, self).prepare_verify_from_gateway()
|
||||
token = self.get_request().GET.get("trackId", None)
|
||||
token = self.get_request().GET.get("trackId")
|
||||
self._set_reference_number(token)
|
||||
self._set_bank_record()
|
||||
|
||||
@@ -114,7 +115,7 @@ class Zibal(BaseBank):
|
||||
|
||||
def _send_data(self, api, data):
|
||||
try:
|
||||
response = requests.post(api, json=data, timeout=5)
|
||||
response = requests.post(api, json=data, timeout=self.get_timeout())
|
||||
except requests.Timeout:
|
||||
logging.exception("Zibal time out gateway {}".format(data))
|
||||
raise BankGatewayConnectionError()
|
||||
|
||||
Regular → Executable
+3
-2
@@ -12,17 +12,18 @@ BANK_CLASS = getattr(
|
||||
"BMI": "azbankgateways.banks.BMI",
|
||||
"SEP": "azbankgateways.banks.SEP",
|
||||
"ZARINPAL": "azbankgateways.banks.Zarinpal",
|
||||
"IDPAY": "azbankgateways.banks.IDPay",
|
||||
"ZIBAL": "azbankgateways.banks.Zibal",
|
||||
"BAHAMTA": "azbankgateways.banks.Bahamta",
|
||||
"MELLAT": "azbankgateways.banks.Mellat",
|
||||
"PAYV1": "azbankgateways.banks.PayV1",
|
||||
"IRANDARGAH": "azbankgateways.banks.irandargah.IranDargah",
|
||||
"ASANPARDAKHT": "azbankgateways.banks.asanpardakht.AsanPardakht",
|
||||
},
|
||||
)
|
||||
_AZ_IRANIAN_BANK_GATEWAYS = getattr(settings, "AZ_IRANIAN_BANK_GATEWAYS", {})
|
||||
BANK_PRIORITIES = _AZ_IRANIAN_BANK_GATEWAYS.get("BANK_PRIORITIES", [])
|
||||
BANK_GATEWAYS = _AZ_IRANIAN_BANK_GATEWAYS.get("GATEWAYS", {})
|
||||
BANK_DEFAULT = _AZ_IRANIAN_BANK_GATEWAYS.get("DEFAULT", "BMI")
|
||||
BANK_TIMEOUT = _AZ_IRANIAN_BANK_GATEWAYS.get("BANK_TIMEOUT", 5)
|
||||
SETTING_VALUE_READER_CLASS = _AZ_IRANIAN_BANK_GATEWAYS.get(
|
||||
"SETTING_VALUE_READER_CLASS", "azbankgateways.readers.DefaultReader"
|
||||
)
|
||||
|
||||
@@ -2,10 +2,11 @@ from .exceptions import ( # noqa
|
||||
AmountDoesNotSupport,
|
||||
AZBankGatewaysException,
|
||||
BankGatewayConnectionError,
|
||||
BankGatewayRejectPayment,
|
||||
BankGatewayStateInvalid,
|
||||
BankGatewayTokenExpired,
|
||||
BankGatewayUnclear,
|
||||
CurrencyDoesNotSupport,
|
||||
SettingDoesNotExist,
|
||||
SafeSettingsEnabled,
|
||||
SettingDoesNotExist,
|
||||
)
|
||||
|
||||
@@ -82,10 +82,6 @@ msgstr ""
|
||||
msgid "Zarinpal"
|
||||
msgstr ""
|
||||
|
||||
#: models/enum.py:10
|
||||
msgid "IDPay"
|
||||
msgstr ""
|
||||
|
||||
#: models/enum.py:11
|
||||
msgid "Zibal"
|
||||
msgstr ""
|
||||
|
||||
@@ -90,10 +90,6 @@ msgstr "بانک سامان"
|
||||
msgid "Zarinpal"
|
||||
msgstr "زرین پال"
|
||||
|
||||
#: models/enum.py:10
|
||||
msgid "IDPay"
|
||||
msgstr "آی دی پی"
|
||||
|
||||
#: models/enum.py:11
|
||||
msgid "Zibal"
|
||||
msgstr "زیبال"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2 on 2026-05-22 16:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('azbankgateways', '0008_alter_bank_order'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='bank',
|
||||
name='bank_type',
|
||||
field=models.CharField(choices=[('BMI', 'BMI'), ('SEP', 'SEP'), ('ZARINPAL', 'Zarinpal'), ('ZIBAL', 'Zibal'), ('BAHAMTA', 'Bahamta'), ('MELLAT', 'Mellat'), ('IRANDARGAH', 'IranDargah'), ('ASANPARDAKHT', 'AsanPardakht')], max_length=50, verbose_name='Bank'),
|
||||
),
|
||||
]
|
||||
@@ -1,6 +1,7 @@
|
||||
import datetime
|
||||
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .enum import BankType, PaymentStatus
|
||||
@@ -23,19 +24,28 @@ class BankManager(models.Manager):
|
||||
return self.get_queryset().active()
|
||||
|
||||
def update_expire_records(self):
|
||||
now = timezone.now()
|
||||
cutoff = now - datetime.timedelta(minutes=15)
|
||||
|
||||
count = (
|
||||
self.active()
|
||||
.filter(
|
||||
status=PaymentStatus.RETURN_FROM_BANK,
|
||||
update_at__lte=datetime.datetime.now() - datetime.timedelta(minutes=15),
|
||||
update_at__lte=cutoff,
|
||||
)
|
||||
.update(status=PaymentStatus.EXPIRE_VERIFY_PAYMENT)
|
||||
)
|
||||
|
||||
count = count + self.active().filter(
|
||||
status=PaymentStatus.REDIRECT_TO_BANK,
|
||||
update_at__lt=datetime.datetime.now() - datetime.timedelta(minutes=15),
|
||||
update_at__lt=cutoff,
|
||||
).update(status=PaymentStatus.EXPIRE_GATEWAY_TOKEN)
|
||||
|
||||
count = count + self.active().filter(
|
||||
status=PaymentStatus.WAITING,
|
||||
update_at__lt=cutoff,
|
||||
).update(status=PaymentStatus.EXPIRE_GATEWAY_TOKEN)
|
||||
|
||||
return count
|
||||
|
||||
def filter_return_from_bank(self):
|
||||
|
||||
Regular → Executable
+2
-2
@@ -6,11 +6,11 @@ class BankType(models.TextChoices):
|
||||
BMI = "BMI", _("BMI")
|
||||
SEP = "SEP", _("SEP")
|
||||
ZARINPAL = "ZARINPAL", _("Zarinpal")
|
||||
IDPAY = "IDPAY", _("IDPay")
|
||||
ZIBAL = "ZIBAL", _("Zibal")
|
||||
BAHAMTA = "BAHAMTA", _("Bahamta")
|
||||
MELLAT = "MELLAT", _("Mellat")
|
||||
PAYV1 = "PAYV1", _("PayV1")
|
||||
IRANDARGAH = "IRANDARGAH", _("IranDargah")
|
||||
ASANPARDAKHT = "ASANPARDAKHT", _("AsanPardakht")
|
||||
|
||||
|
||||
class CurrencyEnum(models.TextChoices):
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import json
|
||||
from urllib import parse
|
||||
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
|
||||
from azbankgateways.types import DictQuerystring
|
||||
|
||||
|
||||
@@ -33,3 +36,28 @@ def split_to_dict_querystring(url: str) -> DictQuerystring:
|
||||
url_parts[5] = ""
|
||||
|
||||
return parse.urlunparse(url_parts), query
|
||||
|
||||
|
||||
def build_full_url(viewname: str, *args, **kwargs):
|
||||
"""
|
||||
Build a full absolute URL including domain if Sites framework is available.
|
||||
Falls back to relative path if no site is configured.
|
||||
"""
|
||||
# Generate the path part
|
||||
path = reverse(viewname, args=args, kwargs=kwargs)
|
||||
|
||||
# Try to use django.contrib.sites if installed
|
||||
if "django.contrib.sites" in settings.INSTALLED_APPS:
|
||||
try:
|
||||
from django.contrib.sites.models import Site
|
||||
|
||||
site = Site.objects.get_current()
|
||||
if site and site.domain:
|
||||
protocol = getattr(settings, "DEFAULT_PROTOCOL", "https")
|
||||
return f"{protocol}://{site.domain}{path}"
|
||||
except Exception:
|
||||
# Any issue with Sites, just return relative path
|
||||
pass
|
||||
|
||||
# Fallback: return only relative path
|
||||
return path
|
||||
|
||||
@@ -58,6 +58,18 @@ CELERY_RESULT_BACKEND = "redis://redis:6379/0"
|
||||
CELERY_TIMEZONE = "UTC"
|
||||
CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler'
|
||||
|
||||
# Kill any task that runs longer than 5 minutes — prevents a hung external
|
||||
# call (Nobitex, bank verify, SMS) from freezing the worker pool indefinitely.
|
||||
CELERY_TASK_TIME_LIMIT = 300
|
||||
CELERY_TASK_SOFT_TIME_LIMIT = 240
|
||||
|
||||
# Recycle each worker child after 200 tasks to release memory/connections.
|
||||
CELERY_WORKER_MAX_TASKS_PER_CHILD = 200
|
||||
|
||||
# Expire task results after 1 hour instead of the 1-day default. We don't
|
||||
# read results, so this just keeps Redis lean.
|
||||
CELERY_RESULT_EXPIRES = 3600
|
||||
|
||||
from celery.schedules import crontab
|
||||
|
||||
CELERY_BEAT_SCHEDULE = {
|
||||
|
||||
@@ -18,6 +18,7 @@ from django.shortcuts import redirect
|
||||
from .permissons import ShopOrderAdminPermission
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import mark_safe
|
||||
from azbankgateways.models.enum import PaymentStatus
|
||||
|
||||
class OrderItemModelInline(StackedInline):
|
||||
model = OrderItemModel
|
||||
@@ -271,6 +272,22 @@ class OrderAdmin(ModelAdmin, ImportExportModelAdmin):
|
||||
logging.debug("This record is verify now.", extra={"pk": bank_record.pk})
|
||||
elif bank_record.order and not bank_record.order.is_paid:
|
||||
bank_record.order.rollback_stock()
|
||||
|
||||
failed_statuses = [
|
||||
PaymentStatus.CANCEL_BY_USER,
|
||||
PaymentStatus.EXPIRE_GATEWAY_TOKEN,
|
||||
PaymentStatus.EXPIRE_VERIFY_PAYMENT,
|
||||
PaymentStatus.ERROR,
|
||||
]
|
||||
failed_records = bank_models.Bank.objects.filter(
|
||||
status__in=failed_statuses,
|
||||
order__isnull=False,
|
||||
order__is_paid=False,
|
||||
order__is_stock_rolled_back=False,
|
||||
).select_related('order')
|
||||
|
||||
for bank_record in failed_records:
|
||||
bank_record.order.rollback_stock()
|
||||
messages.success(request, f"با موفقیت اپدیت شد")
|
||||
return redirect("admin:order_ordermodel_changelist")
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ class OrderListSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = OrderModel
|
||||
fields = ['created_at', 'status', "images", "count",
|
||||
"id", 'final_price', 'order_id', 'verbose_status']
|
||||
"id", 'final_price', 'order_id', 'verbose_status', 'is_paid']
|
||||
read_only_fields = ['count', 'images', 'order_id', 'verbose_status']
|
||||
|
||||
def get_verbose_status(self, obj):
|
||||
@@ -172,12 +172,46 @@ class OrderListSerializer(serializers.ModelSerializer):
|
||||
return filter(lambda x: x is not None, image_list)
|
||||
|
||||
|
||||
class OrderItemDetailSerializer(serializers.ModelSerializer):
|
||||
product = serializers.SerializerMethodField()
|
||||
price = serializers.SerializerMethodField()
|
||||
final_price = serializers.SerializerMethodField()
|
||||
discount_amount = serializers.SerializerMethodField()
|
||||
special_discount = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = OrderItemModel
|
||||
fields = [
|
||||
'id', 'product', 'quantity', 'price', 'final_price',
|
||||
'discount_amount', 'discount_percent', 'special_discount',
|
||||
]
|
||||
|
||||
def get_product(self, obj):
|
||||
return ProductVariantSerialzier(
|
||||
instance=obj.product, context={'request': self.context.get('request')}
|
||||
).data
|
||||
|
||||
def get_price(self, obj):
|
||||
return f'{obj.total_price_before_discount():,.0f} تومانءءء'
|
||||
|
||||
def get_final_price(self, obj):
|
||||
return f'{obj.price_after_special_discount():,.0f} تومانءءء'
|
||||
|
||||
def get_discount_amount(self, obj):
|
||||
return f'{obj.total_product_discount_amount():,.0f} تومانءءء'
|
||||
|
||||
def get_special_discount(self, obj):
|
||||
if obj.special_discount_amount:
|
||||
return f'{obj.special_discount_amount:,.0f} تومانءءء'
|
||||
return None
|
||||
|
||||
|
||||
class OrderGetSerializer(serializers.ModelSerializer):
|
||||
count = serializers.SerializerMethodField()
|
||||
images = serializers.SerializerMethodField()
|
||||
order_id = serializers.SerializerMethodField()
|
||||
verbose_status = serializers.SerializerMethodField()
|
||||
items = OrderItemSerailzier(many=True)
|
||||
items = OrderItemDetailSerializer(many=True)
|
||||
address = UserAddressSerializer()
|
||||
discount_code = DiscountCodeSerializer()
|
||||
|
||||
|
||||
+55
-4
@@ -4,33 +4,84 @@ from azbankgateways import (
|
||||
models as bank_models,
|
||||
default_settings as settings,
|
||||
)
|
||||
from azbankgateways.models.enum import PaymentStatus
|
||||
from .models import OrderModel
|
||||
from account.models import PushSubscription
|
||||
import ghasedak_sms
|
||||
from product.models import ProductImageModel
|
||||
from celery import shared_task
|
||||
from django.db import transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@shared_task
|
||||
def udpate_bank_status():
|
||||
factory = bankfactories.BankFactory()
|
||||
|
||||
# ۱. بروزرسانی رکوردهای منقضی در یک تراکنش جداگانه
|
||||
try:
|
||||
with transaction.atomic():
|
||||
bank_models.Bank.objects.update_expire_records()
|
||||
except Exception as e:
|
||||
logger.error(f"Error in update_expire_records: {e}")
|
||||
|
||||
# ۲. پردازش رکوردهایی که از بانک برگشتهاند
|
||||
for item in bank_models.Bank.objects.filter_return_from_bank():
|
||||
try:
|
||||
with transaction.atomic():
|
||||
bank = factory.create(
|
||||
bank_type=item.bank_type, identifier=item.bank_choose_identifier
|
||||
)
|
||||
bank.verify(item.tracking_code)
|
||||
bank_record = bank_models.Bank.objects.get(tracking_code=item.tracking_code)
|
||||
# استفاده از select_for_update برای جلوگیری از Race Condition
|
||||
bank_record = bank_models.Bank.objects.select_related('order').select_for_update().get(tracking_code=item.tracking_code)
|
||||
|
||||
if bank_record.is_success and bank_record.order:
|
||||
bank_record.order.cart.clear_cart()
|
||||
bank_record.order.is_paid = True
|
||||
bank_record.order.save()
|
||||
logging.debug("This record is verify now.", extra={"pk": bank_record.pk})
|
||||
bank_record.order.save(update_fields=['is_paid'])
|
||||
elif bank_record.order:
|
||||
bank_record.order.rollback_stock()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to verify bank record {item.tracking_code}: {e}")
|
||||
|
||||
failed_statuses = [
|
||||
PaymentStatus.CANCEL_BY_USER,
|
||||
PaymentStatus.EXPIRE_GATEWAY_TOKEN,
|
||||
PaymentStatus.EXPIRE_VERIFY_PAYMENT,
|
||||
PaymentStatus.ERROR,
|
||||
]
|
||||
|
||||
# 1. ابتدا رکوردهای بانکی را بدون select_related پیدا کنید
|
||||
# از select_for_update اینجا استفاده نکنید چون به Order وصل است
|
||||
failed_records = bank_models.Bank.objects.filter(
|
||||
status__in=failed_statuses,
|
||||
order__isnull=False,
|
||||
order__is_paid=False,
|
||||
order__is_stock_rolled_back=False,
|
||||
)
|
||||
|
||||
logger.info(f"Found {failed_records.count()} failed records for rollback.")
|
||||
|
||||
for bank_record in failed_records:
|
||||
try:
|
||||
# استفاده از تراکنش اتمیک برای هر رکورد
|
||||
with transaction.atomic():
|
||||
# 2. حالا خودِ Order مربوطه را با select_for_update قفل کنید
|
||||
# این کار مانع از تداخل با بقیه تسکها میشود
|
||||
order = bank_record.order
|
||||
|
||||
# بررسی مجدد شرط برای اطمینان در لحظه قفل شدن
|
||||
if order and not order.is_paid and not order.is_stock_rolled_back:
|
||||
order.rollback_stock()
|
||||
return 'update bank record is done'
|
||||
logger.info(f"Successfully rolled back stock for bank record {bank_record.id}")
|
||||
else:
|
||||
logger.info(f"Order {order.id} already processed or paid, skipping.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to rollback stock for bank record {bank_record.id}: {str(e)}")
|
||||
|
||||
return "update bank record is done"
|
||||
|
||||
|
||||
@shared_task
|
||||
|
||||
@@ -214,11 +214,13 @@ class OrderlistView(APIView):
|
||||
status_filter = request.query_params.get("status", None)
|
||||
sort = request.query_params.get('sort', None)
|
||||
if status_filter in ['ADMIN_PENDING', 'PENDING', 'POSTED', 'RECEIVED', 'CANCELED', 'REFUNDED']:
|
||||
orders.filter(status=status_filter)
|
||||
orders = orders.filter(status=status_filter)
|
||||
if sort:
|
||||
if sort not in ['created_at', '-created_at', 'final_price', '-final_price']:
|
||||
return Response({'detail': 'پارامتر sort اشتباه است'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
orders = orders.order_by(sort)
|
||||
else:
|
||||
orders = orders.order_by('-created_at')
|
||||
paginator = self.pagination_class()
|
||||
paginated_orders = paginator.paginate_queryset(orders, request)
|
||||
orders_ser = self.serializer_class(
|
||||
@@ -544,6 +546,9 @@ class UserOrderInvoiceView(APIView):
|
||||
bank_detail = Bank.objects.get(tracking_code=order_id)
|
||||
order = bank_detail.order
|
||||
order_id = order.id
|
||||
except Bank.DoesNotExist:
|
||||
try:
|
||||
order = OrderModel.objects.get(id=order_id)
|
||||
except OrderModel.DoesNotExist:
|
||||
return Response(
|
||||
{'detail': 'سفارش مورد نظر یافت نشد'},
|
||||
|
||||
@@ -414,7 +414,7 @@ class ProductModelAdmin(ProductAdminPermission, ModelAdmin, ImportExportModelAdm
|
||||
# compressed_fields = True
|
||||
warn_unsaved_form = True
|
||||
# list_per_page = 2
|
||||
actions_list = ['redirect_to_learn', 'update_products_price']
|
||||
actions_list = ['redirect_to_learn', 'update_products_price', 'resync_all_torob']
|
||||
list_display = ['display_image', 'shop__shop_name', 'view', 'rating', 'category', 'created_at' ,'show_in_website', ]
|
||||
fieldsets = (
|
||||
('فیلد های اصلی', {'fields': ('name', 'description', 'category', 'image', 'related_products','show_in_trends', 'show_in_most_viewed', 'show_in_lot_of_discount', 'show_in_top_seller', 'shop', 'show_in_bot', 'bot_banner'), "classes": ["tab"],}),
|
||||
@@ -497,6 +497,57 @@ class ProductModelAdmin(ProductAdminPermission, ModelAdmin, ImportExportModelAdm
|
||||
messages.success(request, f"قیمت {ProductVariant.objects.all().count()} تنوع محصول اپدیت شد")
|
||||
return redirect("admin:product_productmodel_changelist")
|
||||
|
||||
@action(description="ارسال مجدد همه محصولات به ترب")
|
||||
def resync_all_torob(self, request):
|
||||
from django.conf import settings
|
||||
from product.tasks import send_torob_product_webhook
|
||||
|
||||
if not getattr(settings, "TOROB_PRODUCT_WEBHOOK_TOKEN", None):
|
||||
messages.error(request, "توکن وبهوک ترب در تنظیمات وجود ندارد")
|
||||
return redirect("admin:product_productmodel_changelist")
|
||||
|
||||
product_ids = list(
|
||||
ProductModel.objects.exclude(slug__isnull=True).exclude(slug="").values_list("id", flat=True)
|
||||
)
|
||||
if not product_ids:
|
||||
messages.warning(request, "محصولی برای ارسال یافت نشد")
|
||||
return redirect("admin:product_productmodel_changelist")
|
||||
|
||||
chunk_size = 50
|
||||
queued = 0
|
||||
for start in range(0, len(product_ids), chunk_size):
|
||||
send_torob_product_webhook.delay(product_ids[start:start + chunk_size])
|
||||
queued += 1
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
f"{len(product_ids)} محصول در {queued} بسته برای ارسال به ترب در صف قرار گرفت",
|
||||
)
|
||||
return redirect("admin:product_productmodel_changelist")
|
||||
|
||||
def resync_selected_torob(self, request, queryset):
|
||||
from django.conf import settings
|
||||
from product.tasks import send_torob_product_webhook
|
||||
|
||||
if not getattr(settings, "TOROB_PRODUCT_WEBHOOK_TOKEN", None):
|
||||
messages.error(request, "توکن وبهوک ترب در تنظیمات وجود ندارد")
|
||||
return
|
||||
|
||||
product_ids = list(
|
||||
queryset.exclude(slug__isnull=True).exclude(slug="").values_list("id", flat=True)
|
||||
)
|
||||
if not product_ids:
|
||||
messages.warning(request, "محصول معتبری انتخاب نشد")
|
||||
return
|
||||
|
||||
chunk_size = 50
|
||||
for start in range(0, len(product_ids), chunk_size):
|
||||
send_torob_product_webhook.delay(product_ids[start:start + chunk_size])
|
||||
|
||||
messages.success(request, f"{len(product_ids)} محصول برای ارسال به ترب در صف قرار گرفت")
|
||||
|
||||
resync_selected_torob.short_description = "ارسال محصولات انتخاب شده به ترب"
|
||||
|
||||
def bulk_update_subcategory_action(self, request, queryset):
|
||||
"""اکشن برای تغییر دستهبندی چند محصول همزمان"""
|
||||
|
||||
@@ -535,7 +586,7 @@ class ProductModelAdmin(ProductAdminPermission, ModelAdmin, ImportExportModelAdm
|
||||
)
|
||||
|
||||
bulk_update_subcategory_action.short_description = "تغییر دستهبندی محصولات انتخاب شده"
|
||||
actions = ['bulk_update_subcategory_action']
|
||||
actions = ['bulk_update_subcategory_action', 'resync_selected_torob']
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("product", "0074_productmodel_updated_at_productvariant_updated_at"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="productvariant",
|
||||
name="guarantee",
|
||||
field=models.CharField(
|
||||
default="گرانتی اصالت و سلامت کالا",
|
||||
help_text="این مقدار به ترب ارسال میشود تا محصول بهعنوان نو طبقهبندی شود",
|
||||
max_length=200,
|
||||
verbose_name="گارانتی",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
Add an IMMUTABLE ``normalize_persian(text)`` SQL function and GIN trigram
|
||||
indexes that match it. Lets the search view filter and rank without doing a
|
||||
per-row ``translate()`` on a sequential scan — drops query time from seconds
|
||||
to tens of milliseconds.
|
||||
|
||||
The FROM/TO strings here MUST stay aligned with ``_SQL_NORM_FROM`` /
|
||||
``_SQL_NORM_TO`` in ``product/views.py``. If you change one, change the other
|
||||
and add a follow-up migration that recreates the function + indexes (Postgres
|
||||
matches expression indexes by exact SQL form, so a stale function would
|
||||
silently bypass the indexes).
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
# Mirror of product.views._SQL_NORM_FROM / _SQL_NORM_TO.
|
||||
_SQL_NORM_FROM = (
|
||||
'يك' # Arabic ya/kaf -> Persian
|
||||
'ﻱﻲﻳﻴ' # Arabic ya presentation forms
|
||||
'ﻙﻚﻛﻜ' # Arabic kaf presentation forms
|
||||
'آأإٱ' # alef variants
|
||||
'ؤ' # waw with hamza
|
||||
'ئ' # ya with hamza
|
||||
'ةۀ' # ta marbuta / he with hamza
|
||||
'ﻩﻪﻫﻬ' # he presentation forms
|
||||
'' # ZWNJ, ZWJ -> space
|
||||
'۰۱۲۳۴۵۶۷۸۹' # Persian digits
|
||||
'٠١٢٣٤٥٦٧٨٩' # Arabic-Indic digits
|
||||
# Deletions (no matching char in TO):
|
||||
'ـ' # tatweel
|
||||
'' # LRM, RLM
|
||||
'ًٌٍَُِّْ' # tashkeel
|
||||
)
|
||||
_SQL_NORM_TO = (
|
||||
'یک'
|
||||
'یییی'
|
||||
'کککک'
|
||||
'اااا'
|
||||
'و'
|
||||
'ی'
|
||||
'هه'
|
||||
'هههه'
|
||||
' '
|
||||
'0123456789'
|
||||
'0123456789'
|
||||
)
|
||||
|
||||
|
||||
def _pg_str(s):
|
||||
"""Quote a Python string as a PostgreSQL string literal."""
|
||||
return "'" + s.replace("'", "''") + "'"
|
||||
|
||||
|
||||
CREATE_FUNCTION_SQL = f"""
|
||||
CREATE OR REPLACE FUNCTION normalize_persian(t text) RETURNS text AS $$
|
||||
SELECT lower(translate(t, {_pg_str(_SQL_NORM_FROM)}, {_pg_str(_SQL_NORM_TO)}));
|
||||
$$ LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE;
|
||||
"""
|
||||
|
||||
DROP_FUNCTION_SQL = "DROP FUNCTION IF EXISTS normalize_persian(text);"
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("product", "0075_productvariant_guarantee"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunSQL(
|
||||
sql=CREATE_FUNCTION_SQL,
|
||||
reverse_sql=DROP_FUNCTION_SQL,
|
||||
),
|
||||
# GIN trigram indexes on the normalized expression. PostgreSQL matches
|
||||
# queries that use exactly ``normalize_persian(<col>)`` against these
|
||||
# indexes, so the views.py wrapper must call the SQL function (not
|
||||
# inline translate/lower) for the index to be used.
|
||||
migrations.RunSQL(
|
||||
sql=(
|
||||
"CREATE INDEX IF NOT EXISTS product_norm_name_trgm_idx "
|
||||
"ON product_productmodel "
|
||||
"USING gin (normalize_persian(name) gin_trgm_ops);"
|
||||
),
|
||||
reverse_sql="DROP INDEX IF EXISTS product_norm_name_trgm_idx;",
|
||||
),
|
||||
migrations.RunSQL(
|
||||
sql=(
|
||||
"CREATE INDEX IF NOT EXISTS product_norm_keywords_trgm_idx "
|
||||
"ON product_productmodel "
|
||||
"USING gin (normalize_persian(meta_keywords) gin_trgm_ops);"
|
||||
),
|
||||
reverse_sql="DROP INDEX IF EXISTS product_norm_keywords_trgm_idx;",
|
||||
),
|
||||
migrations.RunSQL(
|
||||
sql=(
|
||||
"CREATE INDEX IF NOT EXISTS subcategory_norm_name_trgm_idx "
|
||||
"ON product_subcategorymodel "
|
||||
"USING gin (normalize_persian(name) gin_trgm_ops);"
|
||||
),
|
||||
reverse_sql="DROP INDEX IF EXISTS subcategory_norm_name_trgm_idx;",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2 on 2026-06-02 05:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('product', '0076_normalize_persian_search'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='productvariant',
|
||||
name='guarantee',
|
||||
field=models.CharField(default='گارانتی اصالت و سلامت کالا', help_text='این مقدار به ترب ارسال می\u200cشود تا محصول به\u200cعنوان نو طبقه\u200cبندی شود', max_length=200, verbose_name='گارانتی'),
|
||||
),
|
||||
]
|
||||
@@ -147,7 +147,7 @@ class DollorModel(models.Model):
|
||||
def get_usd_price(self):
|
||||
try:
|
||||
api_usd = "https://api.nobitex.ir/v2/orderbook/USDTIRT"
|
||||
response = requests.get(api_usd)
|
||||
response = requests.get(api_usd, timeout=5)
|
||||
data = response.json()
|
||||
price = int(data["lastTradePrice"])
|
||||
price_in_usd = price / 10.0
|
||||
@@ -403,6 +403,10 @@ class ProductVariant(DirtyFieldsMixin, models.Model):
|
||||
discount = models.SmallIntegerField(default=0, verbose_name='درصد تخفیف', help_text='این درصد از قیمت نهایی محصول کسر میگردد')
|
||||
color = models.CharField(
|
||||
verbose_name='رنگ', max_length=7, blank=True, null=True)
|
||||
guarantee = models.CharField(
|
||||
max_length=200, default='گارانتی اصالت و سلامت کالا',
|
||||
verbose_name='گارانتی',
|
||||
help_text='این مقدار به ترب ارسال میشود تا محصول بهعنوان نو طبقهبندی شود')
|
||||
images = models.ManyToManyField(ProductImageModel, verbose_name='عکس ها')
|
||||
video = models.FileField(upload_to='product_videos/',
|
||||
blank=True, null=True, verbose_name='ویدیو')
|
||||
|
||||
+13
-10
@@ -17,13 +17,16 @@ TOROB_WEBHOOK_MIN_INTERVAL_SECONDS = 3.1
|
||||
TOROB_WEBHOOK_MAX_RETRIES = 3
|
||||
|
||||
|
||||
def _shop_product_url(product: ProductModel) -> str:
|
||||
def _shop_product_url(product: ProductModel, variant: ProductVariant | None = None) -> str:
|
||||
domain = getattr(settings, "DOMAIN", None) or getattr(settings, "API_DOMAIN", None) or ""
|
||||
if domain.startswith("http://") or domain.startswith("https://"):
|
||||
base = domain.rstrip("/")
|
||||
else:
|
||||
base = f"https://{domain}".rstrip("/") if domain else ""
|
||||
return f"{base}/product/{product.slug}/"
|
||||
url = f"{base}/product/{product.slug}/"
|
||||
if variant is not None:
|
||||
url = f"{url}?variant={variant.pk}"
|
||||
return url
|
||||
|
||||
|
||||
def _variant_page_unique(product: ProductModel, variant: ProductVariant) -> str:
|
||||
@@ -124,18 +127,18 @@ def send_torob_product_webhook(product_ids):
|
||||
if not product.slug:
|
||||
continue
|
||||
|
||||
page_url = _shop_product_url(product)
|
||||
parsed = urlparse(page_url)
|
||||
if not (parsed.scheme in {"http", "https"} and parsed.netloc):
|
||||
logger.warning("Skipping product %s due to invalid page_url: %s", product.pk, page_url)
|
||||
continue
|
||||
hosts.add(parsed.netloc.lower())
|
||||
|
||||
variants = list(product.variants.all())
|
||||
if not variants:
|
||||
continue
|
||||
|
||||
for variant in variants:
|
||||
variant_page_url = _shop_product_url(product, variant)
|
||||
parsed = urlparse(variant_page_url)
|
||||
if not (parsed.scheme in {"http", "https"} and parsed.netloc):
|
||||
logger.warning("Skipping variant %s due to invalid page_url: %s", variant.pk, variant_page_url)
|
||||
continue
|
||||
hosts.add(parsed.netloc.lower())
|
||||
|
||||
# Validate variant has images before sending to Torob
|
||||
# Per spec: image_links is required, so skip variants without images
|
||||
images = list(variant.images.all())
|
||||
@@ -148,7 +151,7 @@ def send_torob_product_webhook(product_ids):
|
||||
|
||||
items.append(
|
||||
{
|
||||
"page_url": page_url,
|
||||
"page_url": variant_page_url,
|
||||
"page_unique": _variant_page_unique(product, variant),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import jwt
|
||||
from django.conf import settings
|
||||
@@ -53,15 +53,16 @@ class TorobProductsRequestSerializer(serializers.Serializer):
|
||||
modes = [name for name in ("page_urls", "page_uniques", "page") if name in attrs]
|
||||
|
||||
if len(modes) != 1:
|
||||
raise serializers.ValidationError(
|
||||
"invalid request body"
|
||||
)
|
||||
raise serializers.ValidationError("invalid request body")
|
||||
|
||||
if "page" in attrs and "sort" not in attrs:
|
||||
raise serializers.ValidationError({"sort": "sort parameter is not provided"})
|
||||
raise serializers.ValidationError({"sort": ["sort parameter is not provided"]})
|
||||
|
||||
if "page" not in attrs and "sort" in attrs:
|
||||
raise serializers.ValidationError({"sort": "sort parameter is invalid"})
|
||||
raise serializers.ValidationError({"sort": ["sort parameter is invalid"]})
|
||||
|
||||
if ("page_urls" in attrs or "page_uniques" in attrs) and "sort" in attrs:
|
||||
raise serializers.ValidationError("invalid request body")
|
||||
|
||||
return attrs
|
||||
|
||||
@@ -79,6 +80,18 @@ def _extract_slug_from_url(value: str) -> str | None:
|
||||
return path.split("/")[-1]
|
||||
|
||||
|
||||
def _extract_variant_id_from_url(value: str) -> str | None:
|
||||
query = urlparse(value).query
|
||||
if not query:
|
||||
return None
|
||||
params = parse_qs(query)
|
||||
variant_values = params.get("variant") or []
|
||||
if not variant_values:
|
||||
return None
|
||||
candidate = variant_values[0].strip()
|
||||
return candidate or None
|
||||
|
||||
|
||||
def _variant_page_unique(product: ProductModel, variant: ProductVariant) -> str:
|
||||
return f"{product.pk}_{variant.pk}"
|
||||
|
||||
@@ -108,13 +121,15 @@ def _absolute_url(request, value: str) -> str:
|
||||
return request.build_absolute_uri(value)
|
||||
|
||||
|
||||
def _shop_product_url(request, product: ProductModel) -> str:
|
||||
def _shop_product_url(request, product: ProductModel, variant: ProductVariant | None = None) -> str:
|
||||
domain = getattr(settings, "DOMAIN", None) or getattr(settings, "API_DOMAIN", None) or request.get_host()
|
||||
if domain.startswith("http://") or domain.startswith("https://"):
|
||||
base = domain.rstrip("/")
|
||||
else:
|
||||
base = f"https://{domain}".rstrip("/")
|
||||
url = f"{base}/product/{product.slug}/"
|
||||
if variant is not None:
|
||||
url = f"{url}?variant={variant.pk}"
|
||||
# Per spec: page_url max 1500 chars
|
||||
return url[:1500]
|
||||
|
||||
@@ -139,9 +154,6 @@ def _variant_spec(variant: ProductVariant | None) -> dict:
|
||||
if variant.color:
|
||||
spec.setdefault("color", variant.color)
|
||||
|
||||
if variant.in_stock is not None:
|
||||
spec.setdefault("in_stock", variant.in_stock)
|
||||
|
||||
return spec
|
||||
|
||||
|
||||
@@ -212,7 +224,7 @@ def _serialize_variant(request, product: ProductModel, variant: ProductVariant)
|
||||
|
||||
payload = {
|
||||
"page_unique": _variant_page_unique(product, variant),
|
||||
"page_url": _shop_product_url(request, product),
|
||||
"page_url": _shop_product_url(request, product, variant),
|
||||
"product_group_id": str(product.pk),
|
||||
"title": _truncate_text(product.name, 500),
|
||||
"subtitle": _truncate_text(product.meta_description, 500),
|
||||
@@ -221,12 +233,11 @@ def _serialize_variant(request, product: ProductModel, variant: ProductVariant)
|
||||
"category_name": _truncate_text(product.category.name if product.category else None, 200),
|
||||
"image_links": _product_image_links(request, product, variant),
|
||||
"spec": _variant_spec(variant),
|
||||
"guarantee": None,
|
||||
"guarantee": _truncate_text(variant.guarantee, 200),
|
||||
"short_desc": _truncate_text(product.description, 500),
|
||||
"date_added": _variant_date_added(product, variant),
|
||||
"date_updated": _variant_date_updated(product, variant),
|
||||
"seller_name": product.shop.shop_name if product.shop else None,
|
||||
"seller_city": _truncate_text(product.shop.city if product.shop else None, 200),
|
||||
# "seller_name": product.shop.shop_name if product.shop else None,
|
||||
}
|
||||
|
||||
if old_price is not None and old_price > current_price:
|
||||
@@ -253,11 +264,18 @@ def _validate_torob_token(request) -> None:
|
||||
key=TOROB_PUBLIC_KEY,
|
||||
algorithms=["EdDSA"],
|
||||
audience=_get_hostname_from_request(request),
|
||||
options={"require": ["exp", "nbf", "aud"]},
|
||||
)
|
||||
logger.debug("Token validated successfully")
|
||||
except jwt.MissingRequiredClaimError as exc:
|
||||
logger.warning(f"Missing required JWT claim: {exc}")
|
||||
raise
|
||||
except jwt.ExpiredSignatureError:
|
||||
logger.warning("Token has expired")
|
||||
raise
|
||||
except jwt.ImmatureSignatureError:
|
||||
logger.warning("Token is not yet valid")
|
||||
raise
|
||||
except jwt.InvalidAudienceError:
|
||||
logger.warning(f"Audience mismatch for request from {request.get_host()}")
|
||||
raise
|
||||
@@ -284,15 +302,6 @@ class TorobProductSyncView(APIView):
|
||||
permission_classes = []
|
||||
|
||||
def post(self, request):
|
||||
# Validate Content-Type header
|
||||
content_type = request.META.get('CONTENT_TYPE', '').split(';')[0].strip()
|
||||
if content_type != 'application/json':
|
||||
logger.warning(f"Invalid Content-Type: {content_type}")
|
||||
return Response(
|
||||
{"error": "Content-Type must be application/json"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
_validate_torob_token(request)
|
||||
except TorobTokenError as exc:
|
||||
@@ -338,27 +347,49 @@ class TorobProductSyncView(APIView):
|
||||
for product in products
|
||||
}
|
||||
|
||||
ordered_products = []
|
||||
ordered_lookups: list[tuple[ProductModel, str | None]] = []
|
||||
for url in requested_urls:
|
||||
slug = _extract_slug_from_url(url)
|
||||
normalized_url = _normalize_url(url)
|
||||
variant_id = _extract_variant_id_from_url(url)
|
||||
product = product_by_slug.get(slug) if slug else None
|
||||
if product is None:
|
||||
product = product_by_url.get(normalized_url)
|
||||
if product is not None and product not in ordered_products:
|
||||
ordered_products.append(product)
|
||||
if product is None:
|
||||
continue
|
||||
ordered_lookups.append((product, variant_id))
|
||||
|
||||
serialized_products = []
|
||||
for product in ordered_products:
|
||||
seen: set[str] = set()
|
||||
for product, variant_id in ordered_lookups:
|
||||
variants = list(product.variants.all())
|
||||
if not variants:
|
||||
continue
|
||||
|
||||
if variant_id:
|
||||
variant = next((v for v in variants if str(v.pk) == variant_id), None)
|
||||
if not variant:
|
||||
continue
|
||||
image_links = _product_image_links(request, product, variant)
|
||||
if not image_links:
|
||||
continue
|
||||
page_unique = _variant_page_unique(product, variant)
|
||||
if page_unique in seen:
|
||||
continue
|
||||
seen.add(page_unique)
|
||||
serialized_products.append(_serialize_variant(request, product, variant))
|
||||
continue
|
||||
|
||||
variants.sort(key=_variant_sort_key)
|
||||
for variant in variants:
|
||||
image_links = _product_image_links(request, product, variant)
|
||||
# Skip variants without images as per spec requirement
|
||||
if not image_links:
|
||||
continue
|
||||
page_unique = _variant_page_unique(product, variant)
|
||||
if page_unique in seen:
|
||||
continue
|
||||
seen.add(page_unique)
|
||||
serialized_products.append(_serialize_variant(request, product, variant))
|
||||
|
||||
return Response(
|
||||
|
||||
+186
-19
@@ -1,3 +1,4 @@
|
||||
import re
|
||||
from .models import ProductModel
|
||||
from rest_framework import serializers
|
||||
from django.core.paginator import Paginator
|
||||
@@ -6,8 +7,8 @@ from .models import *
|
||||
from .serializers import *
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from django.db.models import Q, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.db.models import Q, Value, Case, When, FloatField, F, CharField, Func
|
||||
from django.db.models.functions import Coalesce, Length
|
||||
from django.contrib.postgres.search import TrigramSimilarity
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework.permissions import IsAuthenticatedOrReadOnly
|
||||
@@ -21,6 +22,179 @@ from home.models import ShowCaseSlider
|
||||
from home.serializers import ShowCaseSliderSerialzier
|
||||
from order.models import Cart, CartItem
|
||||
from django.db.models import Min, Max, Value
|
||||
|
||||
|
||||
_PERSIAN_CHAR_MAP = str.maketrans({
|
||||
# Arabic letters -> Persian equivalents
|
||||
'ي': 'ی', 'ك': 'ک',
|
||||
# Arabic ya/kaf presentation forms -> Persian
|
||||
'ﻱ': 'ی', 'ﻲ': 'ی', 'ﻳ': 'ی', 'ﻴ': 'ی',
|
||||
'ﻙ': 'ک', 'ﻚ': 'ک', 'ﻛ': 'ک', 'ﻜ': 'ک',
|
||||
# Alef variants -> bare alef (so "ایفون" matches "آیفون")
|
||||
'آ': 'ا', 'أ': 'ا', 'إ': 'ا', 'ٱ': 'ا',
|
||||
# Hamza on waw/ya -> bare letter
|
||||
'ؤ': 'و',
|
||||
'ئ': 'ی',
|
||||
# Ta marbuta / he variants -> he
|
||||
'ة': 'ه', 'ۀ': 'ه',
|
||||
'ﻩ': 'ه', 'ﻪ': 'ه', 'ﻫ': 'ه', 'ﻬ': 'ه',
|
||||
# Tatweel - drop
|
||||
'ـ': '',
|
||||
# Tashkeel (diacritics) - drop
|
||||
'ً': '', 'ٌ': '', 'ٍ': '', 'َ': '', 'ُ': '', 'ِ': '', 'ّ': '', 'ْ': '',
|
||||
# Zero-width / direction marks
|
||||
'': ' ', '': ' ',
|
||||
'': '', '': '',
|
||||
# Arabic-Indic / Persian digits -> ASCII
|
||||
'۰': '0', '۱': '1', '۲': '2', '۳': '3', '۴': '4',
|
||||
'۵': '5', '۶': '6', '۷': '7', '۸': '8', '۹': '9',
|
||||
'٠': '0', '١': '1', '٢': '2', '٣': '3', '٤': '4',
|
||||
'٥': '5', '٦': '6', '٧': '7', '٨': '8', '٩': '9',
|
||||
})
|
||||
|
||||
|
||||
def _normalize_search_text(text):
|
||||
"""Normalize a search string to handle Persian/Arabic variants, ZWNJ, and case."""
|
||||
if not text:
|
||||
return ''
|
||||
return re.sub(r'\s+', ' ', text.translate(_PERSIAN_CHAR_MAP)).strip().lower()
|
||||
|
||||
|
||||
# SQL-side equivalent of _PERSIAN_CHAR_MAP for PostgreSQL translate().
|
||||
# Each char at position i in FROM is replaced by char at position i in TO;
|
||||
# chars past len(TO) are deleted entirely. This must mirror the Python map so
|
||||
# stored values and query strings normalize to the same form.
|
||||
_SQL_NORM_FROM = (
|
||||
'يك' # Arabic ya/kaf -> Persian
|
||||
'ﻱﻲﻳﻴ' # Arabic ya presentation forms
|
||||
'ﻙﻚﻛﻜ' # Arabic kaf presentation forms
|
||||
'آأإٱ' # alef variants
|
||||
'ؤ' # waw with hamza
|
||||
'ئ' # ya with hamza
|
||||
'ةۀ' # ta marbuta / he with hamza
|
||||
'ﻩﻪﻫﻬ' # he presentation forms
|
||||
'' # ZWNJ, ZWJ -> space
|
||||
'۰۱۲۳۴۵۶۷۸۹' # Persian digits
|
||||
'٠١٢٣٤٥٦٧٨٩' # Arabic-Indic digits
|
||||
# Deletions (no matching char in TO):
|
||||
'ـ' # tatweel
|
||||
'' # LRM, RLM
|
||||
'ًٌٍَُِّْ' # tashkeel
|
||||
)
|
||||
_SQL_NORM_TO = (
|
||||
'یک'
|
||||
'یییی'
|
||||
'کککک'
|
||||
'اااا'
|
||||
'و'
|
||||
'ی'
|
||||
'هه'
|
||||
'هههه'
|
||||
' '
|
||||
'0123456789'
|
||||
'0123456789'
|
||||
)
|
||||
|
||||
|
||||
def NormalizePersian(expression):
|
||||
"""SQL expression that calls the ``normalize_persian(text)`` Postgres function.
|
||||
|
||||
The function (defined in migration 0076) computes ``lower(translate(t, FROM, TO))``
|
||||
and is marked IMMUTABLE so GIN trigram indexes on ``normalize_persian(name)``
|
||||
etc. can be matched by the planner. Calling the function (instead of inlining
|
||||
translate/lower) is what lets queries use those indexes — otherwise every
|
||||
search is a full sequential scan.
|
||||
"""
|
||||
return Func(expression, function='normalize_persian', output_field=CharField())
|
||||
|
||||
|
||||
def _apply_product_search(queryset, search_query):
|
||||
"""Filter and rank a Product queryset by a (possibly Persian) search query.
|
||||
|
||||
Returns (queryset, normalized_query). The queryset is annotated with
|
||||
``similarity`` so callers can ``order_by('-similarity', ...)``. When no
|
||||
product strictly matches, falls back to a looser similarity-based filter
|
||||
so the user sees suggestions instead of an empty page.
|
||||
"""
|
||||
normalized_query = _normalize_search_text(search_query) if search_query else ''
|
||||
if not normalized_query:
|
||||
return queryset, ''
|
||||
|
||||
tokens = [t for t in normalized_query.split(' ') if len(t) >= 2]
|
||||
|
||||
annotated = queryset.annotate(
|
||||
norm_name=NormalizePersian('name'),
|
||||
norm_keywords=NormalizePersian(Coalesce('meta_keywords', Value(''))),
|
||||
norm_category=NormalizePersian(Coalesce('category__name', Value(''))),
|
||||
norm_desc=NormalizePersian(Coalesce('description', Value(''))),
|
||||
).annotate(
|
||||
name_sim=TrigramSimilarity(F('norm_name'), normalized_query),
|
||||
keywords_sim=TrigramSimilarity(F('norm_keywords'), normalized_query),
|
||||
category_sim=TrigramSimilarity(F('norm_category'), normalized_query),
|
||||
desc_sim=TrigramSimilarity(F('norm_desc'), normalized_query),
|
||||
).annotate(
|
||||
# Word-boundary aware bonuses. The space-padded variants are what make
|
||||
# "چای" rank above "چایساز" — the former matches "چای " (word boundary)
|
||||
# while the latter only matches the glued prefix.
|
||||
#
|
||||
# Uses case-sensitive lookups (__contains, not __icontains) because both
|
||||
# sides are already lowercased: __icontains would wrap the expression in
|
||||
# UPPER(...) and break the GIN trigram index match.
|
||||
match_bonus=Case(
|
||||
When(norm_name__exact=normalized_query, then=Value(10.0)),
|
||||
When(norm_name__startswith=normalized_query + ' ', then=Value(6.0)),
|
||||
When(norm_name__startswith=normalized_query, then=Value(3.5)),
|
||||
When(norm_name__contains=' ' + normalized_query + ' ', then=Value(3.0)),
|
||||
When(norm_name__contains=' ' + normalized_query, then=Value(2.5)),
|
||||
When(norm_name__contains=normalized_query + ' ', then=Value(2.5)),
|
||||
When(norm_name__contains=normalized_query, then=Value(1.5)),
|
||||
default=Value(0.0),
|
||||
output_field=FloatField(),
|
||||
)
|
||||
).annotate(
|
||||
similarity=(
|
||||
F('match_bonus')
|
||||
+ F('name_sim') * Value(2.0)
|
||||
+ F('keywords_sim') * Value(0.8)
|
||||
+ F('category_sim') * Value(0.4)
|
||||
+ F('desc_sim') * Value(0.15)
|
||||
)
|
||||
)
|
||||
|
||||
if tokens:
|
||||
# Token AND filter. Limited to fields we have GIN trigram indexes for
|
||||
# (name, keywords, category.name in migration 0076) — including
|
||||
# description or slug here would force a sequential scan on the OR
|
||||
# branch and undo the index speedup. Description still contributes via
|
||||
# ``desc_sim`` to ranking on the already-narrowed result set.
|
||||
token_filter = Q()
|
||||
for token in tokens:
|
||||
token_filter &= (
|
||||
Q(norm_name__contains=token)
|
||||
| Q(norm_keywords__contains=token)
|
||||
| Q(norm_category__contains=token)
|
||||
)
|
||||
strict_filter = (
|
||||
token_filter
|
||||
| Q(name_sim__gte=0.45)
|
||||
| Q(keywords_sim__gte=0.5)
|
||||
)
|
||||
else:
|
||||
strict_filter = Q(name_sim__gte=0.4) | Q(keywords_sim__gte=0.4)
|
||||
|
||||
strict_products = annotated.filter(strict_filter).distinct()
|
||||
if strict_products.exists():
|
||||
return strict_products, normalized_query
|
||||
|
||||
# No strict matches — relax thresholds so the user gets "similar"
|
||||
# suggestions instead of an empty result page.
|
||||
loose_filter = (
|
||||
Q(name_sim__gte=0.18)
|
||||
| Q(keywords_sim__gte=0.22)
|
||||
| Q(category_sim__gte=0.3)
|
||||
| Q(match_bonus__gt=0)
|
||||
)
|
||||
return annotated.filter(loose_filter).distinct(), normalized_query
|
||||
# class APIView(APIView):
|
||||
# def __init__(self, *args, **kwargs):
|
||||
# super().__init__(*args, **kwargs)
|
||||
@@ -324,18 +498,9 @@ class AllProductsView(APIView):
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Search
|
||||
# Search (Persian-aware, with typo tolerance + similar-results fallback)
|
||||
search_query = request.query_params.get('search')
|
||||
if search_query:
|
||||
products = products.annotate(
|
||||
similarity=(
|
||||
TrigramSimilarity('name', search_query) +
|
||||
TrigramSimilarity(
|
||||
Coalesce('description', Value('')),
|
||||
search_query
|
||||
)
|
||||
)
|
||||
).filter(similarity__gt=0.1)
|
||||
products, normalized_query = _apply_product_search(products, search_query)
|
||||
|
||||
# Price annotation (IMPORTANT for sorting)
|
||||
products = products.annotate(
|
||||
@@ -376,8 +541,10 @@ class AllProductsView(APIView):
|
||||
|
||||
elif sort_by in ['price', '-price']:
|
||||
products = products.order_by('min_price' if sort_by == 'price' else '-min_price')
|
||||
elif search_query:
|
||||
products = products.order_by('-similarity', 'name')
|
||||
elif normalized_query:
|
||||
# Tie-break on shorter name: ensures "چای" outranks "چای ساز"
|
||||
# when their bonus-adjusted similarities are close.
|
||||
products = products.order_by('-similarity', Length('norm_name'), 'name')
|
||||
else:
|
||||
products = products.order_by('name')
|
||||
|
||||
@@ -522,11 +689,9 @@ class ShowCaseProductsView(APIView):
|
||||
if has_discount:
|
||||
products = products.filter(variants__discount__gt=0).distinct()
|
||||
|
||||
# Search filter
|
||||
# Search filter (Persian-aware, with typo tolerance + similar-results fallback)
|
||||
search_query = request.query_params.get('search', None)
|
||||
if search_query:
|
||||
products = products.filter(Q(name__icontains=search_query) | Q(
|
||||
description__icontains=search_query))
|
||||
products, normalized_query = _apply_product_search(products, search_query)
|
||||
|
||||
# Price filters
|
||||
price_gte = request.query_params.get('price_gte', None)
|
||||
@@ -543,6 +708,8 @@ class ShowCaseProductsView(APIView):
|
||||
sort_by = request.query_params.get('sort', None)
|
||||
if sort_by in ['name', '-name', 'created_at', '-created_at']:
|
||||
products = products.order_by(sort_by)
|
||||
elif normalized_query:
|
||||
products = products.order_by('-similarity', Length('norm_name'), 'name')
|
||||
else:
|
||||
products = products.order_by('name')
|
||||
|
||||
|
||||
@@ -109,11 +109,13 @@ class TicketListView(APIView):
|
||||
filter_by = request.query_params.get('filter', None)
|
||||
sort = request.query_params.get('sort', None)
|
||||
if filter_by:
|
||||
tickets.filter(status=str(filter_by))
|
||||
tickets = tickets.filter(status=str(filter_by))
|
||||
if sort:
|
||||
if sort not in ['created_at', '-created_at']:
|
||||
return Response({'detail': 'wrong sort paramter'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
tickets.order_by(sort)
|
||||
tickets = tickets.order_by(sort)
|
||||
else:
|
||||
tickets = tickets.order_by('-created_at')
|
||||
paginator = self.pagination_class()
|
||||
paginated_tickets = paginator.paginate_queryset(tickets, request)
|
||||
tickets_ser = self.serializer_class(instance=paginated_tickets, many=True, context={'request': request})
|
||||
|
||||
+5
-4
@@ -17,8 +17,7 @@ services:
|
||||
|
||||
django:
|
||||
container_name: shop_backend
|
||||
build:
|
||||
context: ./backend
|
||||
image: fix_update_bank:latest
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
@@ -108,8 +107,10 @@ services:
|
||||
redis:
|
||||
container_name: hshop_redis
|
||||
image: redis:alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
command: >
|
||||
redis-server
|
||||
--maxmemory 512mb
|
||||
--maxmemory-policy volatile-lru
|
||||
networks:
|
||||
- default
|
||||
restart: always
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
/*
|
||||
Layout fade animation
|
||||
*/
|
||||
|
||||
.layout-fade-leave-active,
|
||||
.layout-fade-enter-active {
|
||||
transition: transform 0.15s ease-in-out, opacity 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.layout-fade-enter-to,
|
||||
.layout-fade-leave-from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.layout-fade-enter-from,
|
||||
.layout-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
Zoom animation
|
||||
*/
|
||||
|
||||
+58
-5
@@ -32,7 +32,8 @@
|
||||
"ogl": "^1.0.11",
|
||||
"reka-ui": "^1.0.0-alpha.11",
|
||||
"sanitize-html": "^2.17.3",
|
||||
"swiper": "^11.2.10",
|
||||
"sharp": "^0.34.4",
|
||||
"swiper": "^12.1.4",
|
||||
"universal-cookie": "^7.2.2",
|
||||
"vue": "^3.5.33",
|
||||
"vue-image-zoomer": "^2.4.4",
|
||||
@@ -360,6 +361,56 @@
|
||||
|
||||
"@iconify/vue": ["@iconify/vue@5.0.0", "", { "dependencies": { "@iconify/types": "^2.0.0" }, "peerDependencies": { "vue": ">=3" } }, "sha512-C+KuEWIF5nSBrobFJhT//JS87OZ++QDORB6f2q2Wm6fl2mueSTpFBeBsveK0KW9hWiZ4mNiPjsh6Zs4jjdROSg=="],
|
||||
|
||||
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
|
||||
|
||||
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
|
||||
|
||||
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
||||
|
||||
"@internationalized/date": ["@internationalized/date@3.12.1", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ=="],
|
||||
|
||||
"@internationalized/number": ["@internationalized/number@3.6.6", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-iFgmQaXHE0vytNfpLZWOC2mEJCBRzcUxt53Xf/yCXG93lRvqas237i3r7X4RKMwO3txiyZD4mQjKAByFv6UGSQ=="],
|
||||
@@ -1966,7 +2017,7 @@
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"sharp": ["sharp@0.32.6", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.2", "node-addon-api": "^6.1.0", "prebuild-install": "^7.1.1", "semver": "^7.5.4", "simple-get": "^4.0.1", "tar-fs": "^3.0.4", "tunnel-agent": "^0.6.0" } }, "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w=="],
|
||||
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
@@ -2064,7 +2115,7 @@
|
||||
|
||||
"svgo": ["svgo@3.3.3", "", { "dependencies": { "commander": "^7.2.0", "css-select": "^5.1.0", "css-tree": "^2.3.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.0.0", "sax": "^1.5.0" }, "bin": "./bin/svgo" }, "sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng=="],
|
||||
|
||||
"swiper": ["swiper@11.2.10", "", {}, "sha512-RMeVUUjTQH+6N3ckimK93oxz6Sn5la4aDlgPzB+rBrG/smPdCTicXyhxa+woIpopz+jewEloiEE3lKo1h9w2YQ=="],
|
||||
"swiper": ["swiper@12.1.4", "", {}, "sha512-bihiwoKMOQwW8FfdUbo1DgkVH25E+4ZELIq0oopL1KTKBteLuaTMi/wwFjMxtlhTkk45k3XQ89D1Fvv0spSqBA=="],
|
||||
|
||||
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||
|
||||
@@ -2484,6 +2535,8 @@
|
||||
|
||||
"ipx/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
||||
|
||||
"ipx/sharp": ["sharp@0.32.6", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.2", "node-addon-api": "^6.1.0", "prebuild-install": "^7.1.1", "semver": "^7.5.4", "simple-get": "^4.0.1", "tar-fs": "^3.0.4", "tunnel-agent": "^0.6.0" } }, "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w=="],
|
||||
|
||||
"is-expression/acorn": ["acorn@7.4.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A=="],
|
||||
|
||||
"is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
|
||||
@@ -2570,8 +2623,6 @@
|
||||
|
||||
"send/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"sharp/node-addon-api": ["node-addon-api@6.1.0", "", {}, "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA=="],
|
||||
|
||||
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
@@ -2656,6 +2707,8 @@
|
||||
|
||||
"glob/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
|
||||
|
||||
"ipx/sharp/node-addon-api": ["node-addon-api@6.1.0", "", {}, "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA=="],
|
||||
|
||||
"lazystream/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||
|
||||
"lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
@@ -58,7 +58,10 @@ const formRules = computed(() => {
|
||||
},
|
||||
postal_code: {
|
||||
required: helpers.withMessage("فیلد کد پستی الزامی می باشد", required),
|
||||
minLength: helpers.withMessage("فیلد کد پستی حداقل 10 کرکتر می باشد", minLength(10)),
|
||||
exactLength: helpers.withMessage("فیلد کد پستی باید دقیقا 10 کرکتر باشد", (value: unknown) => {
|
||||
if (value === null || value === undefined) return false;
|
||||
return String(value).length === 10;
|
||||
}),
|
||||
},
|
||||
address: {
|
||||
required: helpers.withMessage("فیلد آدرس کامل الزامی می باشد", required),
|
||||
|
||||
@@ -25,7 +25,7 @@ const { date } = toRefs(props);
|
||||
|
||||
// state
|
||||
|
||||
const createdAt = usePersianTimeAgo(new Date(date.value));
|
||||
const createdAt = usePersianTimeAgo(date.value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -21,14 +21,14 @@
|
||||
</ClientOnly>
|
||||
|
||||
<div class="flex flex-col gap-4 items-center justify-center relative z-20">
|
||||
<div class="flex items-center flex-col gap-8 pb-[10px] pt-[80px] lg:pt-[100px] lg:pb-[50px] justify-center">
|
||||
<div class="flex items-center flex-col gap-8 pb-2.5 pt-20 lg:pt-25 lg:pb-12.5 justify-center">
|
||||
<img
|
||||
src="/img/heymlz/heymlz-small-idle.gif"
|
||||
loading="lazy"
|
||||
fetch-priority="low"
|
||||
class="size-[150px] lg:size-[220px] rounded-full drop-shadow-2xl"
|
||||
class="size-37.5 lg:size-55 rounded-full drop-shadow-2xl"
|
||||
/>
|
||||
<span class="font-bold text-2xl lg:text-5xl text-gradient bg-gradient-to-l from-blue-500 to-blue-700">
|
||||
<span class="font-bold text-2xl lg:text-5xl text-gradient bg-linear-to-l from-blue-500 to-blue-700">
|
||||
فروشگاه هی ملز
|
||||
</span>
|
||||
</div>
|
||||
@@ -36,7 +36,7 @@
|
||||
<div
|
||||
class="w-full flex max-lg:flex-col justify-between pt-16 pb-12 max-lg:gap-16 container items-center lg:items-start relative z-20"
|
||||
>
|
||||
<div class="flex flex-col gap-4 max-w-[300px]">
|
||||
<div class="flex flex-col gap-4 max-w-75">
|
||||
<h3 class="font-bold text-lg xl:text-3xl max-lg:text-center text-white">فروشگاه هی ملز</h3>
|
||||
|
||||
<p class="text-md font-thin leading-[175%] mt-4 max-lg:text-center text-slate-300 max-lg:text-xs">
|
||||
@@ -46,7 +46,7 @@
|
||||
<div class="flex items-center gap-4 mt-6 max-lg:justify-center">
|
||||
<NuxtLink
|
||||
to="#"
|
||||
class="flex-center size-[1.5rem]"
|
||||
class="flex-center size-6"
|
||||
>
|
||||
<Icon
|
||||
name="ci:instagram"
|
||||
@@ -56,7 +56,7 @@
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="#"
|
||||
class="flex-center size-[1.5rem]"
|
||||
class="flex-center size-6"
|
||||
>
|
||||
<Icon
|
||||
name="ci:facebook"
|
||||
@@ -66,7 +66,7 @@
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="#"
|
||||
class="flex-center size-[1.5rem]"
|
||||
class="flex-center size-6"
|
||||
>
|
||||
<Icon
|
||||
name="ci:tiktok"
|
||||
@@ -76,7 +76,7 @@
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="#"
|
||||
class="flex-center size-[1.5rem]"
|
||||
class="flex-center size-6"
|
||||
>
|
||||
<Icon
|
||||
name="ci:youtube"
|
||||
@@ -96,11 +96,7 @@
|
||||
<a href="tel:02193111026"> 93111026-021 </a>
|
||||
</span>
|
||||
<br />
|
||||
<span>
|
||||
برای پشتیبانی : داخلی ۱
|
||||
<br />
|
||||
برای مدیریت : داخلی ۴
|
||||
</span>
|
||||
<span> ارتباط با پشتیبانی: داخلی ۱ </span>
|
||||
</li>
|
||||
<li>ایمیل: npsayna@gmail.com</li>
|
||||
<li><NuxtLink to="contact-us">تیکت</NuxtLink></li>
|
||||
@@ -114,6 +110,15 @@
|
||||
<li>
|
||||
<NuxtLink to="product-return"> رویه های بازگرداندن کالا </NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink to="shipping-methods"> شرایط و روش های ارسال </NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink to="test-return"> روشهای تست و مرجوعی </NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink to="contact-us">گزارش باگ</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink to="#"> پرسش های متداول </NuxtLink>
|
||||
</li>
|
||||
@@ -121,7 +126,7 @@
|
||||
<NuxtLink to="privacy"> حریم خصوصی </NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink to="contact-us">گزارش باگ</NuxtLink>
|
||||
<NuxtLink to="payment-methods"> شرایط و روش های پرداخت </NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
type Props = {
|
||||
variant?: "solid" | "outlined";
|
||||
disabled?: boolean;
|
||||
modelValue: string;
|
||||
modelValue: number | string | undefined;
|
||||
error?: boolean;
|
||||
options?: string[];
|
||||
placeholder?: string;
|
||||
@@ -13,7 +13,7 @@ type Props = {
|
||||
};
|
||||
|
||||
type Emits = {
|
||||
"update:modelValue": [value: string];
|
||||
"update:modelValue": [value: number | string | undefined];
|
||||
};
|
||||
|
||||
// props
|
||||
@@ -34,7 +34,7 @@ const emit = defineEmits<Emits>();
|
||||
// computed
|
||||
|
||||
const selectedValue = computed({
|
||||
get: () => modelValue.value ?? undefined,
|
||||
get: () => modelValue.value,
|
||||
set: (value: string) => emit("update:modelValue", value),
|
||||
});
|
||||
|
||||
|
||||
@@ -16,15 +16,14 @@ const { data: product } = useGetProduct(id);
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex p-1 items-center justify-center rounded-full bg-success-500">
|
||||
<Icon
|
||||
name="ci:check"
|
||||
class="size-4 **:stroke-white"
|
||||
name="ci:bi-check"
|
||||
class="size-4 **:fill-white"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 ">
|
||||
<span class="typo-label-sm whitespace-nowrap">{{ product?.customer_pickup_title }}</span>
|
||||
<span class="typo-p-sm whitespace-nowrap">{{ product?.customer_pickup_description }}</span>
|
||||
<span class="typo-p-sm">{{ product?.customer_pickup_description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="typo-p-xs max-sm:hidden">فروشگاه هیملز</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -31,7 +31,10 @@ const onSwiper = (swiper: SwiperClass) => {
|
||||
<section class="w-full flex flex-col gap-10 md:gap-16 lg:container">
|
||||
<div class="w-full flex justify-between items-center max-lg:container">
|
||||
<div class="flex gap-2 items-center">
|
||||
<NuxtImg :src="iconImage" class="size-8 sm:size-14" />
|
||||
<NuxtImg
|
||||
:src="iconImage"
|
||||
class="size-8 sm:size-14"
|
||||
/>
|
||||
<span class="text-black typo-h-6 md:typo-h-5 lg:typo-h-4">
|
||||
{{ title }}
|
||||
</span>
|
||||
@@ -69,6 +72,7 @@ const onSwiper = (swiper: SwiperClass) => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ClientOnly>
|
||||
<div class="w-full">
|
||||
<Swiper
|
||||
:slides-per-view="1.5"
|
||||
@@ -108,6 +112,19 @@ const onSwiper = (swiper: SwiperClass) => {
|
||||
</SwiperSlide>
|
||||
</Swiper>
|
||||
</div>
|
||||
<template #fallback>
|
||||
<div class="w-full flex sm:grid justify-between sm:grid-cols-4 gap-6">
|
||||
<div
|
||||
class="bg-neutral-100 items-stretch w-25 sm:size-full rounded-e-2xl sm:rounded-2xl sm:aspect-square"
|
||||
/>
|
||||
<div class="bg-neutral-100 size-full rounded-2xl aspect-square" />
|
||||
<div
|
||||
class="bg-neutral-100 items-stretch w-25 sm:size-full rounded-s-2xl sm:rounded-2xl sm:aspect-square"
|
||||
/>
|
||||
<div class="bg-neutral-100 size-full rounded-2xl aspect-square max-sm:hidden" />
|
||||
</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
rate: number
|
||||
}
|
||||
rate: number;
|
||||
haveRate?: boolean;
|
||||
};
|
||||
|
||||
// props
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="typo-p-sm">
|
||||
<span class="typo-p-sm font-normal translate-y-px">
|
||||
<template v-if="haveRate">
|
||||
{{ rate }}
|
||||
</template>
|
||||
<template v-else> ( بدون نظر ) </template>
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<Icon
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-6">
|
||||
<span class="typo-p-md text-black">
|
||||
اشتراک گذاری:
|
||||
</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<NuxtLink>
|
||||
<Icon name="ci:instagram" class="**:stroke-slate-500 size-6" />
|
||||
</NuxtLink>
|
||||
<NuxtLink>
|
||||
<Icon name="ci:facebook" class="**:stroke-slate-500 size-6" />
|
||||
</NuxtLink>
|
||||
<NuxtLink>
|
||||
<Icon name="ci:tiktok" class="**:stroke-slate-500 size-6" />
|
||||
</NuxtLink>
|
||||
<NuxtLink>
|
||||
<Icon name="ci:youtube" class="**:stroke-slate-500 size-6" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
@@ -72,6 +72,7 @@ const changeSlide = (id: number) => {
|
||||
</div>
|
||||
|
||||
<div class="relative w-full">
|
||||
<ClientOnly>
|
||||
<Swiper
|
||||
:slides-per-view="3"
|
||||
:space-between="20"
|
||||
@@ -111,6 +112,14 @@ const changeSlide = (id: number) => {
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
</Swiper>
|
||||
<template #fallback>
|
||||
<div class="w-full grid grid-cols-3 gap-6">
|
||||
<div class="bg-neutral-100 size-full rounded-2xl aspect-square" />
|
||||
<div class="bg-neutral-100 size-full rounded-2xl aspect-square" />
|
||||
<div class="bg-neutral-100 size-full rounded-2xl aspect-square" />
|
||||
</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
|
||||
<div
|
||||
v-if="slides.length > 3"
|
||||
|
||||
@@ -1,23 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
// type
|
||||
|
||||
type Props = {
|
||||
title: string,
|
||||
date: string,
|
||||
username: string,
|
||||
content: string,
|
||||
}
|
||||
title: string;
|
||||
date: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
content: string;
|
||||
rate: number;
|
||||
};
|
||||
|
||||
// props
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const { date } = toRefs(props);
|
||||
const { date, first_name, last_name } = toRefs(props);
|
||||
|
||||
// state
|
||||
|
||||
const formattedDate = useDateFormat(date.value, "YYYY/MM/DD");
|
||||
|
||||
// computed
|
||||
|
||||
const username = computed(() => {
|
||||
if (first_name.value.length === 0 || last_name.value.length === 0) {
|
||||
return "کاربر هیملز";
|
||||
}
|
||||
|
||||
return `${first_name.value} ${last_name.value}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -25,7 +35,7 @@ const formattedDate = useDateFormat(date.value, "YYYY/MM/DD");
|
||||
<div class="flex justify-between items-start w-full max-sm:flex-col gap-4">
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-lg font-semibold sm:typo-h-6 text-black">
|
||||
خیلی محصول خوبی بودددد
|
||||
{{ title }}
|
||||
</span>
|
||||
<span class="typo-p-xs sm:typo-p-sm text-slate-500">
|
||||
{{ username }}
|
||||
@@ -33,7 +43,7 @@ const formattedDate = useDateFormat(date.value, "YYYY/MM/DD");
|
||||
{{ formattedDate }}
|
||||
</span>
|
||||
</div>
|
||||
<Rating :rate="2"/>
|
||||
<Rating :rate="rate" />
|
||||
</div>
|
||||
<div class="typo-p-md">
|
||||
{{ content }}
|
||||
|
||||
@@ -72,6 +72,7 @@ const parallaxStyle = computed(() => {
|
||||
fetch-priority="low"
|
||||
class="group-hover:scale-105 transition-transform duration-200 size-full object-contain absolute inset-0"
|
||||
alt="product-background"
|
||||
:quality="5"
|
||||
/>
|
||||
|
||||
<!-- <div
|
||||
|
||||
@@ -42,8 +42,12 @@ const onSlideChange = (swiper: SwiperClass) => {
|
||||
<span class="text-white typo-h-6 md:typo-h-5 lg:typo-h-4 min-[2000px]:typo-h-2"> دسته بندی ها </span>
|
||||
</div>
|
||||
|
||||
<div class="w-full mt-44 lg:mt-64 relative">
|
||||
<div
|
||||
class="w-full relative"
|
||||
:class="swiper_instance ? 'mt-44 lg:mt-64' : 'mt-24'"
|
||||
>
|
||||
<NuxtImg
|
||||
v-if="swiper_instance"
|
||||
class="aspect-square w-[210px] sm:w-[240px] md:w-[300px] lg:w-[350px] 2xl:w-[420px] translate-y-[-136px] sm:translate-y-[-156px] md:translate-y-[-195px] lg:translate-y-[-228px] 2xl:translate-y-[-273px] absolute left-1/2 -translate-x-1/2 z-10"
|
||||
:style="{
|
||||
filter: 'drop-shadow(0px 0px 20px rgba(0, 0, 0, 0.4))',
|
||||
@@ -52,6 +56,8 @@ const onSlideChange = (swiper: SwiperClass) => {
|
||||
fetch-priority="low"
|
||||
src="/img/heymlz/heymlz-category-seat.gif"
|
||||
/>
|
||||
|
||||
<ClientOnly>
|
||||
<Swiper
|
||||
:loop="true"
|
||||
:centered-slides="true"
|
||||
@@ -124,8 +130,22 @@ const onSlideChange = (swiper: SwiperClass) => {
|
||||
</SwiperSlide>
|
||||
</Swiper>
|
||||
|
||||
<template #fallback>
|
||||
<div
|
||||
v-if="!swiper_instance?.isBeginning"
|
||||
class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 min-[3000px]:grid-cols-6 gap-12 lg:gap-20 px-12 lg:px-20"
|
||||
>
|
||||
<div class="w-full aspect-square bg-neutral-800/75 rounded-150"></div>
|
||||
<div class="w-full aspect-square bg-neutral-800/75 rounded-150"></div>
|
||||
<div class="w-full aspect-square bg-neutral-800/75 rounded-150 max-md:hidden"></div>
|
||||
<div class="w-full aspect-square bg-neutral-800/75 rounded-150 max-xl:hidden"></div>
|
||||
<div class="w-full aspect-square bg-neutral-800/75 rounded-150 max-[3000px]:hidden"></div>
|
||||
<div class="w-full aspect-square bg-neutral-800/75 rounded-150 max-[3000px]:hidden"></div>
|
||||
</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
|
||||
<div
|
||||
v-if="swiper_instance && !swiper_instance?.isBeginning"
|
||||
@click="swiper_instance?.slidePrev()"
|
||||
:style="{
|
||||
right: `calc(50% - ${slideWidth / 2}px - 20px)`,
|
||||
@@ -139,7 +159,7 @@ const onSlideChange = (swiper: SwiperClass) => {
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!swiper_instance?.isEnd"
|
||||
v-if="swiper_instance && !swiper_instance?.isEnd"
|
||||
@click="swiper_instance?.slideNext()"
|
||||
:style="{
|
||||
left: `calc(50% - ${slideWidth / 2}px - 20px)`,
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
<div class="flex flex-col items-center gap-3 mb-10 lg:mb-16 typo-h-6 md:typo-h-5 lg:typo-h-3 text-black">
|
||||
منتظر یک اتفاق جذاب باشید...
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-xl lg:rounded-3xl w-full bg-neutral-200">
|
||||
<div class="overflow-hidden rounded-xl lg:rounded-3xl w-full h-fit">
|
||||
<video
|
||||
src="/video/curtain-red.webm"
|
||||
class="w-full"
|
||||
class="w-full bg-neutral-200"
|
||||
autoplay
|
||||
muted
|
||||
loop
|
||||
|
||||
@@ -117,7 +117,7 @@ const initializeGsapAnimation = () => {
|
||||
const resetTimelineForMobile = () => {
|
||||
gsap.to("#header-navbar", {
|
||||
background: "white",
|
||||
boxShadow: "0 10px 15px -3px rgba(0,0,0,0.05)"
|
||||
boxShadow: "0 10px 15px -3px rgba(0,0,0,0.05)",
|
||||
});
|
||||
gsap.to(".header-navbar-item", {
|
||||
filter: "invert(0%)",
|
||||
@@ -182,13 +182,13 @@ onUnmounted(() => {
|
||||
<div
|
||||
id="header-slider-wrapper"
|
||||
class="relative"
|
||||
:class="swiper_instance ? '' : 'bg-black min-h-svh'"
|
||||
:class="swiper_instance ? '' : 'min-h-svh bg-black'"
|
||||
>
|
||||
<Swiper
|
||||
ref="observerTarget"
|
||||
:class="swiper_instance ? '' : 'opacity-0'"
|
||||
:slides-per-view="slidesPerView"
|
||||
:loop="true"
|
||||
:loop="false"
|
||||
:centered-slides="true"
|
||||
:breakpoints="{
|
||||
768: {
|
||||
@@ -212,7 +212,7 @@ onUnmounted(() => {
|
||||
webkit-playsinline
|
||||
class="slide-video absolute inset-0 size-full object-cover brightness-90"
|
||||
:src="slide.video"
|
||||
poster="/img/test-thumbnail.jpeg"
|
||||
:poster="slide.image!"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ watch(
|
||||
} else {
|
||||
activeSlideVideo.value = "none";
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
@@ -67,7 +67,7 @@ watch(
|
||||
if (clipPercent >= 1 && clipPercent <= 99) {
|
||||
clipPathPercent.value = clipPercent;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -76,11 +76,11 @@ watch(
|
||||
<div>
|
||||
<div class="flex flex-col items-center gap-3 mb-10 lg:mb-16">
|
||||
<span class="typo-p-sm md:typo-p-md text-slate-500"> مقایسه محصولات </span>
|
||||
<span class="typo-h-6 md:typo-h-5 lg:typo-h-3 text-black"> تفاوت محصلات ما را ببینید </span>
|
||||
<span class="typo-h-6 md:typo-h-5 lg:typo-h-3 text-black"> محصولات ما را ببینید </span>
|
||||
</div>
|
||||
<div
|
||||
ref="previewContainerEl"
|
||||
class="rounded-200 overflow-hidden h-[70svh] max-h-200 relative"
|
||||
class="rounded-200 overflow-hidden h-[70svh] max-h-200 relative bg-neutral-100"
|
||||
>
|
||||
<Transition name="fade">
|
||||
<NuxtImg
|
||||
@@ -97,7 +97,8 @@ watch(
|
||||
muted
|
||||
playsinline
|
||||
webkit-playsinline
|
||||
src="/video/vid-3.mp4"
|
||||
loop
|
||||
:src="homeData!.difreance_section.video1"
|
||||
class="select-none absolute size-full object-cover brightness-[95%]"
|
||||
/>
|
||||
</Transition>
|
||||
@@ -121,7 +122,8 @@ watch(
|
||||
muted
|
||||
playsinline
|
||||
webkit-playsinline
|
||||
src="/video/vid-3.mp4"
|
||||
loop
|
||||
:src="homeData!.difreance_section.video2"
|
||||
class="overlay-image select-none absolute object-cover size-full brightness-[95%]"
|
||||
/>
|
||||
</Transition>
|
||||
@@ -145,7 +147,7 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-0 p-6 md:p-10 w-full flex justify-between items-end transition-opacity">
|
||||
<!-- <div class="absolute bottom-0 p-6 md:p-10 w-full flex justify-between items-end transition-opacity">
|
||||
<div
|
||||
class="flex flex-col gap-2 text-black transition-opacity"
|
||||
:class="activeSlideVideo === 'right' ? 'opacity-0' : ''"
|
||||
@@ -174,7 +176,7 @@ watch(
|
||||
{{ homeData!.difreance_section.title2 }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,35 +3,87 @@
|
||||
|
||||
import useGetComments from "~/composables/api/product/useGetComments";
|
||||
import useCreateComment from "~/composables/api/product/useCreateComment";
|
||||
import useRateProduct from "~/composables/api/product/useRateProduct";
|
||||
import { useAuth } from "~/composables/api/auth/useAuth";
|
||||
import useGetProduct from "~/composables/api/product/useGetProduct";
|
||||
|
||||
// props
|
||||
|
||||
type Props = {
|
||||
product: Product;
|
||||
};
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
// state
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const id = route.params.id as string | undefined;
|
||||
const page = ref(1);
|
||||
|
||||
const { token } = useAuth();
|
||||
const userTitle = ref("");
|
||||
const userComment = ref("");
|
||||
const selectedRating = ref(5);
|
||||
|
||||
const showMoreComments = ref(false);
|
||||
|
||||
const { data: comments, refetch: refetchComments } = useGetComments(id, page);
|
||||
const { data: comments, refetch: refetchComments } = useGetComments(id);
|
||||
const { refetch: refetchProduct } = useGetProduct(id);
|
||||
const { mutateAsync: createComment, isPending: isCreateCommentPending } = useCreateComment(id);
|
||||
const { mutateAsync: rateProduct, isPending: isRateProductPending } = useRateProduct(props.product.slug);
|
||||
|
||||
watch(
|
||||
() => props.product.user_rating,
|
||||
(newUserRating) => {
|
||||
if (token.value && newUserRating !== null) {
|
||||
selectedRating.value = newUserRating;
|
||||
} else {
|
||||
selectedRating.value = 5;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const canSubmitComment = computed(() => {
|
||||
return userTitle.value.trim().length > 0 && userComment.value.trim().length > 0;
|
||||
});
|
||||
|
||||
const hasUserRated = computed(() => {
|
||||
return token.value && props.product.user_rating !== null;
|
||||
});
|
||||
|
||||
// methods
|
||||
|
||||
const submitComment = async () => {
|
||||
if (userComment.value.length > 3) {
|
||||
await createComment({
|
||||
content: userComment.value,
|
||||
});
|
||||
|
||||
userComment.value = "";
|
||||
|
||||
await refetchComments();
|
||||
if (!canSubmitComment.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const promises: Promise<any>[] = [
|
||||
createComment({
|
||||
title: userTitle.value,
|
||||
content: userComment.value,
|
||||
}),
|
||||
];
|
||||
|
||||
// Only submit rating if user hasn't rated before
|
||||
if (!hasUserRated.value) {
|
||||
promises.push(
|
||||
rateProduct({
|
||||
rating: selectedRating.value,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
userTitle.value = "";
|
||||
userComment.value = "";
|
||||
selectedRating.value = 5;
|
||||
|
||||
await refetchProduct();
|
||||
await refetchComments();
|
||||
};
|
||||
|
||||
// computed
|
||||
@@ -50,22 +102,88 @@ const limitedComments = computed(() => {
|
||||
v-if="!!comments"
|
||||
class="bg-slate-50"
|
||||
>
|
||||
<div class="flex relative gap-8 my-42 container max-lg:flex-col">
|
||||
<div class="flex relative gap-8 my-24 sm:my-42 container max-lg:flex-col">
|
||||
<div
|
||||
class="sticky top-0 flex flex-col gap-6 lg:min-w-[400px] h-fit bg-white p-8 rounded-xl border-[0.5px] border-slate-200"
|
||||
class="sticky top-0 flex flex-col gap-6 lg:min-w-100 h-fit bg-white p-8 rounded-xl border-[0.5px] border-slate-200"
|
||||
>
|
||||
<h3 class="typo-h-6 max-sm:text-xl md:typo-h-5 lg:typo-h-4">نظرات کاربران</h3>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Rating :rate="2" />
|
||||
<!-- <div class="flex flex-col gap-2">
|
||||
<Rating :rate="props.product.rating" />
|
||||
<span class="typo-p-sm"> بر اساس {{ comments?.count }} نظر </span>
|
||||
</div>
|
||||
</div> -->
|
||||
<form
|
||||
@submit.prevent="submitComment"
|
||||
class="flex flex-col gap-6"
|
||||
>
|
||||
<div v-if="token">
|
||||
<div
|
||||
v-if="hasUserRated"
|
||||
class="flex flex-col gap-3 px-4 py-3 bg-white rounded-lg border border-slate-300"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="typo-p-xs text-slate-700 font-semibold">امتیاز قبلی شما</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
v-for="star in 5"
|
||||
:key="`prev-${star}`"
|
||||
type="button"
|
||||
disabled
|
||||
class="size-9 rounded-full flex-center cursor-not-allowed opacity-75"
|
||||
:class="
|
||||
star <= (props.product.user_rating || 0) ? 'bg-amber-50' : 'bg-slate-50'
|
||||
"
|
||||
>
|
||||
<Icon
|
||||
name="ci:star-solid"
|
||||
class="size-5"
|
||||
:class="
|
||||
star <= (props.product.user_rating || 0)
|
||||
? '**:fill-yellow-500'
|
||||
: '**:fill-slate-300'
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
<span class="typo-p-sm font-semibold text-slate-600 mr-2">{{
|
||||
props.product.user_rating
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<span class="typo-p-sm text-slate-500">امتیاز شما</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
v-for="star in 5"
|
||||
:key="star"
|
||||
type="button"
|
||||
class="size-9 rounded-full flex-center transition-colors"
|
||||
:class="star <= selectedRating ? 'bg-amber-50' : 'bg-slate-50'"
|
||||
:disabled="!token || isCreateCommentPending || isRateProductPending"
|
||||
@click="selectedRating = star"
|
||||
>
|
||||
<Icon
|
||||
name="ci:star-solid"
|
||||
class="size-5"
|
||||
:class="star <= selectedRating ? '**:fill-yellow-500' : '**:fill-slate-300'"
|
||||
/>
|
||||
</button>
|
||||
<span class="typo-p-sm font-semibold text-slate-500 mr-2">{{ selectedRating }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
v-model="userTitle"
|
||||
:disabled="!token"
|
||||
placeholder="عنوان نظر را بنویسید..."
|
||||
variant="outlined"
|
||||
/>
|
||||
<textarea
|
||||
:disabled="!token"
|
||||
class="w-full min-h-[125px] resize-none sm:min-h-[200px] field-sizing-content rounded-xl bg-white p-4 border border-slate-200 placeholder:text-xs lg:placeholder:text-sm placeholder:font-normal"
|
||||
class="w-full min-h-31.25 resize-none sm:min-h-50 field-sizing-content rounded-xl bg-white p-4 border border-slate-300 placeholder:text-xs lg:placeholder:text-sm placeholder:font-normal"
|
||||
v-model="userComment"
|
||||
placeholder="نظر خود را بنویسید..."
|
||||
/>
|
||||
@@ -73,10 +191,10 @@ const limitedComments = computed(() => {
|
||||
v-if="token"
|
||||
type="submit"
|
||||
class="rounded-full w-full"
|
||||
:loading="isCreateCommentPending"
|
||||
:disabled="isCreateCommentPending"
|
||||
:loading="isCreateCommentPending || isRateProductPending"
|
||||
:disabled="isCreateCommentPending || isRateProductPending || !canSubmitComment"
|
||||
>
|
||||
نظر بنویسید
|
||||
ثبت نظر
|
||||
</Button>
|
||||
<NuxtLink
|
||||
v-else
|
||||
@@ -86,7 +204,7 @@ const limitedComments = computed(() => {
|
||||
type="button"
|
||||
class="rounded-full w-full"
|
||||
>
|
||||
وارد شوید
|
||||
برای ثبت نظر وارد شوید
|
||||
</Button>
|
||||
</NuxtLink>
|
||||
</form>
|
||||
@@ -95,10 +213,12 @@ const limitedComments = computed(() => {
|
||||
<Comment
|
||||
v-for="comment in limitedComments"
|
||||
:key="comment.id"
|
||||
title=""
|
||||
:title="comment.title"
|
||||
:content="comment.content"
|
||||
:date="comment.timestamp"
|
||||
:username="'منصور مرزبان'"
|
||||
:first_name="comment.user.first_name"
|
||||
:last_name="comment.user.last_name"
|
||||
:rate="comment.user_rating ?? 0"
|
||||
/>
|
||||
|
||||
<div
|
||||
@@ -109,9 +229,10 @@ const limitedComments = computed(() => {
|
||||
v-if="showMoreComments"
|
||||
:total="comments.count"
|
||||
:items="comments.results.map((item, i) => ({ type: 'page', value: i }))"
|
||||
:per-page="8"
|
||||
/>
|
||||
<Button
|
||||
v-else
|
||||
v-else-if="comments.count > 3"
|
||||
type="button"
|
||||
variant="primary"
|
||||
@click="showMoreComments = !showMoreComments"
|
||||
@@ -123,15 +244,15 @@ const limitedComments = computed(() => {
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="h-[400px] lg:flex-grow w-full border-[0.5px] flex-col-center border-slate-200 bg-white rounded-xl"
|
||||
class="h-100 lg:grow w-full border-[0.5px] flex-col-center border-slate-200 bg-white rounded-xl"
|
||||
>
|
||||
<NuxtImg
|
||||
src="/img/heymlz/heymlz-contact-us.gif"
|
||||
loading="lazy"
|
||||
fetch-priority="low"
|
||||
class="w-[200px] lg:w-[300px] translate-y-[-25px]"
|
||||
class="w-50 lg:w-75 -translate-y-6.25"
|
||||
/>
|
||||
<span class="text-xl text-black font-semibold translate-y-[-25px]"> هیچ نظری ثبت نشده است </span>
|
||||
<span class="text-xl text-black font-semibold -translate-y-6.25"> هیچ نظری ثبت نشده است </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ const { selectedVariant } = inject("productVariant") as ProductVariantProvideTyp
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="w-full container py-20 flex flex-col gap-y-[1.5rem]">
|
||||
<section class="w-full container sm:pt-20 flex flex-col gap-y-6">
|
||||
<div class="w-full flex">
|
||||
<span class="text-black max-lg:hidden typo-h-4 mb-4"> جزئیات محصول </span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts" setup>
|
||||
// imports
|
||||
|
||||
import { useAuth } from "~/composables/api/auth/useAuth";
|
||||
import useGetProduct from "~/composables/api/product/useGetProduct";
|
||||
import useSaveProduct from "~/composables/api/product/useSaveProduct";
|
||||
import { useToast } from "~/composables/global/useToast";
|
||||
import { QUERY_KEYS } from "~/constants";
|
||||
|
||||
// states
|
||||
|
||||
const route = useRoute();
|
||||
const id = route.params.id as string | undefined;
|
||||
|
||||
const { $queryClient: queryClient } = useNuxtApp();
|
||||
|
||||
const { token } = useAuth();
|
||||
const { addToast } = useToast();
|
||||
|
||||
const { mutateAsync: saveProduct, isPending: isSaveProductPending } = useSaveProduct();
|
||||
const { data: product, refetch: refetchProduct, isFetching: isFetchingPending } = useGetProduct(id);
|
||||
|
||||
// methods
|
||||
|
||||
const saveProductHandler = async () => {
|
||||
if (!!token.value) {
|
||||
await saveProduct({ product_slug: product.value!.slug });
|
||||
await queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.product] });
|
||||
} else {
|
||||
addToast({
|
||||
options: { status: "info" },
|
||||
message: "برای ذخیره کردن لطفا وارد شوید.",
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
@click="saveProductHandler"
|
||||
:disabled="isSaveProductPending || isFetchingPending"
|
||||
class="px-2 sm:px-3 py-2 flex items-center gap-2 bg-slate-50 border-slate-200 border rounded-lg flex-center"
|
||||
>
|
||||
<span class="typo-label-sm max-sm:hidden"> ذخیره </span>
|
||||
|
||||
<Icon
|
||||
v-if="isSaveProductPending || isFetchingPending"
|
||||
name="ci:svg-spinners-180-ring-with-bg"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-else
|
||||
:class="product?.added_to_favorites ? '**:fill-blue-400' : ''"
|
||||
:name="product?.added_to_favorites ? 'bi-bookmark-fill' : 'ci:bi-bookmark'"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts" setup>
|
||||
// imports
|
||||
|
||||
import { useToast } from "~/composables/global/useToast";
|
||||
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
product: Product;
|
||||
};
|
||||
|
||||
// props
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const { product } = toRefs(props);
|
||||
|
||||
// states
|
||||
|
||||
const { addToast } = useToast();
|
||||
|
||||
// methods
|
||||
|
||||
const shareProduct = async () => {
|
||||
const shareData = {
|
||||
title: product.value.name,
|
||||
text: `لینک اشتراک گذاری محصول ${product.value.name}`,
|
||||
url: window.location.href,
|
||||
};
|
||||
|
||||
// Native share
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share(shareData);
|
||||
} catch (error) {
|
||||
console.error("Share canceled or failed", error);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback → copy link
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareData.url);
|
||||
|
||||
addToast({
|
||||
message: "لینک کالا کپی شد !",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Clipboard failed", error);
|
||||
addToast({
|
||||
options: { status: "error" },
|
||||
message: "کپی لینک کالا با خطا مواجه شد !",
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
@click="shareProduct"
|
||||
class="px-2 py-2 flex items-center gap-2 bg-slate-50 border-slate-200 border rounded-lg flex-center"
|
||||
>
|
||||
<span class="typo-label-sm max-sm:hidden"> ارسال </span>
|
||||
|
||||
<Icon
|
||||
name="ci:bi-share"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
@@ -5,8 +5,6 @@ import useGetProduct from "~/composables/api/product/useGetProduct";
|
||||
import type { ProductVariantProvideType } from "~/pages/product/[id].vue";
|
||||
import useAddCartItem from "~/composables/api/orders/useAddCartItem";
|
||||
import { useAuth } from "~/composables/api/auth/useAuth";
|
||||
import useSaveProduct from "~/composables/api/product/useSaveProduct";
|
||||
import { QUERY_KEYS } from "~/constants";
|
||||
|
||||
// state
|
||||
|
||||
@@ -14,11 +12,9 @@ const route = useRoute();
|
||||
const id = route.params.id as string | undefined;
|
||||
|
||||
const { token } = useAuth();
|
||||
const { $queryClient: queryClient } = useNuxtApp();
|
||||
|
||||
const { data: product, refetch: refetchProduct, isFetching: isFetchingPending } = useGetProduct(id);
|
||||
const { mutateAsync: addCartItem, isPending: isAddCartItemPending } = useAddCartItem();
|
||||
const { mutateAsync: saveProduct, isPending: isSaveProductPending } = useSaveProduct();
|
||||
|
||||
const selectedVariantId = ref(product.value!.variants[0].id);
|
||||
const selectedQuantity = ref(1);
|
||||
@@ -40,11 +36,6 @@ const addItemToCart = async () => {
|
||||
await refetchProduct();
|
||||
};
|
||||
|
||||
const saveProductHandler = async () => {
|
||||
await saveProduct({ product_slug: product.value!.slug });
|
||||
await queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.product] });
|
||||
};
|
||||
|
||||
// watch
|
||||
|
||||
watch([selectedVariantId, product], ([selectedVariantId, product]) => {
|
||||
@@ -61,7 +52,7 @@ watch(
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
@@ -72,36 +63,37 @@ watch(
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex max-lg:flex-col lg:gap-12 xl:gap-16 container pt-[5rem] pb-28">
|
||||
<div class="flex max-lg:flex-col lg:gap-12 xl:gap-16 container pt-8 sm:pt-20 pb-28">
|
||||
<div class="flex flex-col gap-3 lg:hidden">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<NuxtLink
|
||||
to="#"
|
||||
class="typo-label-sm"
|
||||
>
|
||||
محصولات
|
||||
</NuxtLink>
|
||||
<Icon
|
||||
name="ci:bi-chevron-left"
|
||||
size="14"
|
||||
/>
|
||||
<NuxtLink
|
||||
to="#"
|
||||
class="typo-label-sm"
|
||||
>
|
||||
{{ product!.category.name }}
|
||||
</NuxtLink>
|
||||
<button
|
||||
@click="saveProductHandler"
|
||||
:disabled="isSaveProductPending || isFetchingPending || !token"
|
||||
class="size-10 bg-slate-50 border-slate-200 border rounded-lg flex-center"
|
||||
>
|
||||
<Icon
|
||||
v-if="isSaveProductPending || isFetchingPending"
|
||||
name="ci:svg-spinners-180-ring-with-bg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Icon
|
||||
v-else
|
||||
:class="product?.added_to_favorites ? '**:fill-blue-400' : ''"
|
||||
:name="product?.added_to_favorites ? 'bi-bookmark-fill' : 'ci:bi-bookmark'"
|
||||
/>
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<ShareButton :product="product!" />
|
||||
<SaveButton />
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="typo-h-6 xs:typo-h-5 sm:typo-h-4 lg:typo-h-2">
|
||||
{{ product!.name }}
|
||||
@@ -136,7 +128,10 @@ watch(
|
||||
<span class="max-sm:hidden"> تخفیف درصد </span>
|
||||
</div>
|
||||
</div>
|
||||
<Rating :rate="3" />
|
||||
<Rating
|
||||
:rate="product!.rating"
|
||||
:have-rate="product!.rating !== 0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
@@ -146,28 +141,28 @@ watch(
|
||||
/>
|
||||
<div class="lg:w-1/2 flex flex-col gap-3 mt-12">
|
||||
<div class="flex items-center justify-between w-full max-lg:hidden">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<NuxtLink
|
||||
to="#"
|
||||
class="typo-label-sm"
|
||||
>
|
||||
محصولات
|
||||
</NuxtLink>
|
||||
<Icon
|
||||
name="ci:bi-chevron-left"
|
||||
size="14"
|
||||
/>
|
||||
<NuxtLink
|
||||
to="#"
|
||||
class="typo-label-sm"
|
||||
>
|
||||
{{ product!.category.name }}
|
||||
</NuxtLink>
|
||||
<button
|
||||
@click="saveProductHandler"
|
||||
:disabled="isSaveProductPending || isFetchingPending || !token"
|
||||
class="size-10 bg-slate-50 border-slate-200 border rounded-lg flex-center"
|
||||
>
|
||||
<Icon
|
||||
v-if="isSaveProductPending || isFetchingPending"
|
||||
name="ci:svg-spinners-180-ring-with-bg"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
v-else
|
||||
:class="product?.added_to_favorites ? '**:fill-blue-400' : ''"
|
||||
:name="product?.added_to_favorites ? 'bi-bookmark-fill' : 'ci:bi-bookmark'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ShareButton :product="product!" />
|
||||
<SaveButton />
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="typo-h-4 xl:typo-h-3 max-lg:hidden">
|
||||
{{ product!.name }}
|
||||
@@ -204,13 +199,15 @@ watch(
|
||||
</div>
|
||||
|
||||
<Rating
|
||||
:rate="3"
|
||||
:rate="product!.rating"
|
||||
:have-rate="product!.rating !== 0"
|
||||
class="sm:hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Rating
|
||||
:rate="3"
|
||||
:rate="product!.rating"
|
||||
:have-rate="product!.rating !== 0"
|
||||
class="max-sm:hidden"
|
||||
/>
|
||||
</div>
|
||||
@@ -238,7 +235,7 @@ watch(
|
||||
<div class="flex items-center gap-6 flex-wrap">
|
||||
<ProductVariant
|
||||
@click="selectedVariantId = variant.id"
|
||||
v-for="variant in product!.variants.filter(p => p.color === selectedColor)"
|
||||
v-for="variant in product!.variants.filter((p) => p.color === selectedColor)"
|
||||
:key="variant.id"
|
||||
:variantDetail="variant"
|
||||
:isSelected="selectedVariantId === variant.id"
|
||||
@@ -338,8 +335,6 @@ watch(
|
||||
</div>
|
||||
|
||||
<InfoCard />
|
||||
|
||||
<Share />
|
||||
</div>
|
||||
|
||||
<ProductDescription
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// imports
|
||||
|
||||
import useDownloadInvoice from "~/composables/api/orders/useDownloadInvoice";
|
||||
import usePersianDate from "~/composables/global/usePersianDate";
|
||||
|
||||
// types
|
||||
|
||||
@@ -15,6 +16,10 @@ const props = defineProps<Props>();
|
||||
|
||||
const { data } = toRefs(props);
|
||||
|
||||
// state
|
||||
|
||||
const { formatToPersian } = usePersianDate();
|
||||
|
||||
// queries
|
||||
|
||||
const { downloadFn, downloadIsLoading } = useDownloadInvoice(String(data.value.id));
|
||||
@@ -30,7 +35,7 @@ const { downloadFn, downloadIsLoading } = useDownloadInvoice(String(data.value.i
|
||||
{{ data.order_id ? `${data.order_id}#` : "--" }}
|
||||
</td>
|
||||
<td class="w-3/12 px-6 py-6 text-xs lg:text-sm font-medium whitespace-pre shrink-0">
|
||||
{{ data.created_at ?? "--" }}
|
||||
{{ data.created_at ? formatToPersian(data.created_at) : "--" }}
|
||||
</td>
|
||||
<td class="w-2/12 px-6 py-6 text-xs lg:text-sm whitespace-pre shrink-0">
|
||||
{{ data.count ? data.count : "--" }}
|
||||
@@ -53,10 +58,25 @@ const { downloadFn, downloadIsLoading } = useDownloadInvoice(String(data.value.i
|
||||
</div>
|
||||
</td>
|
||||
<td class="w-1/12 px-6 py-6 shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<NuxtLink :to="{ name: 'profile-purchases-and-orders-id', params: { id: data.id } }">
|
||||
<button
|
||||
class="size-9 lg:size-10 flex-center border border-slate-200 rounded-md"
|
||||
aria-label="مشاهده جزئیات سفارش"
|
||||
>
|
||||
<Icon
|
||||
name="ci:eye-open"
|
||||
class="**:stroke-black"
|
||||
size="20"
|
||||
/>
|
||||
</button>
|
||||
</NuxtLink>
|
||||
<button
|
||||
v-if="data.is_paid"
|
||||
@click="!downloadIsLoading ? downloadFn() : undefined"
|
||||
:disabled="downloadIsLoading"
|
||||
class="size-9 lg:size-10 flex-center border border-slate-200 rounded-md"
|
||||
aria-label="دانلود فاکتور"
|
||||
>
|
||||
<Icon
|
||||
v-if="downloadIsLoading"
|
||||
@@ -71,6 +91,7 @@ const { downloadFn, downloadIsLoading } = useDownloadInvoice(String(data.value.i
|
||||
size="20"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
@@ -23,7 +23,10 @@
|
||||
<Skeleton class="w-full !h-10 !rounded-sm" />
|
||||
</td>
|
||||
<td class="w-1/12 px-6 py-6 shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<Skeleton class="!size-10 !rounded-sm" />
|
||||
<Skeleton class="!size-10 !rounded-sm" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
@@ -21,7 +21,7 @@ const { is_user, files, date } = toRefs(props);
|
||||
|
||||
// state
|
||||
|
||||
const timeAgo = usePersianTimeAgo(new Date(date.value));
|
||||
const timeAgo = usePersianTimeAgo(date.value);
|
||||
|
||||
// queries
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@ const { data } = toRefs(props);
|
||||
|
||||
// computed
|
||||
|
||||
const createdTimeAgo = usePersianTimeAgo(new Date(data.value.created_at));
|
||||
const updatedTimeAgo = usePersianTimeAgo(new Date(data.value.updated_at));
|
||||
const createdTimeAgo = usePersianTimeAgo(data.value.created_at);
|
||||
const updatedTimeAgo = usePersianTimeAgo(data.value.updated_at);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -35,9 +35,6 @@ const useGetChat = (productId: string | number, enabled: Ref<boolean>) => {
|
||||
offset,
|
||||
limit,
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoyMTY3ODE2OTAwLCJpYXQiOjE3MzU4MTY5MDAsImp0aSI6ImQwN2E2Y2Y2NzgwZjRlNTE5NWIzOGQxMTAzYzU4NDQ3IiwidXNlcl9pZCI6NX0.slwd7ZSV7nUXEuDTYwwHUOo9ekCefwEEL4kVv2vSTFo`,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -29,6 +29,8 @@ export type GetHomeDataResponse = {
|
||||
description2: string;
|
||||
link1: string;
|
||||
link2: string;
|
||||
video1: string;
|
||||
video2: string;
|
||||
};
|
||||
show_case_slider: {
|
||||
id: number;
|
||||
|
||||
@@ -26,7 +26,7 @@ const useGetAllOrders = () => {
|
||||
const handleGetAllOrders = async () => {
|
||||
const { data } = await axios.get<GetAllOrdersResponse>(API_ENDPOINTS.orders.get_all, {
|
||||
params: {
|
||||
sort: sort.value ?? "created_at",
|
||||
sort: sort.value ?? "-created_at",
|
||||
status: status.value,
|
||||
offset: Number(page.value) * 10 - 10,
|
||||
limit: 10,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// imports
|
||||
|
||||
import { useQuery } from "@tanstack/vue-query";
|
||||
import type { ComputedRef } from "vue";
|
||||
import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
|
||||
|
||||
// types
|
||||
|
||||
export type GetOrderResponse = OrderDetail;
|
||||
|
||||
const useGetOrder = (id: ComputedRef<string>) => {
|
||||
// state
|
||||
|
||||
const { $axios: axios } = useNuxtApp();
|
||||
|
||||
// methods
|
||||
|
||||
const handleGetOrder = async () => {
|
||||
const { data } = await axios.get<GetOrderResponse>(`${API_ENDPOINTS.orders.get_one}/${id.value}`);
|
||||
return data;
|
||||
};
|
||||
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEYS.order, id],
|
||||
queryFn: () => handleGetOrder(),
|
||||
enabled: computed(() => !!id.value),
|
||||
});
|
||||
};
|
||||
|
||||
export default useGetOrder;
|
||||
@@ -6,6 +6,7 @@ import { API_ENDPOINTS } from "~/constants";
|
||||
// types
|
||||
|
||||
export type CreateCommentRequest = {
|
||||
title : string,
|
||||
content: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,28 +1,35 @@
|
||||
// imports
|
||||
|
||||
import { useQuery } from "@tanstack/vue-query";
|
||||
import { useAppParams } from "~/composables/global/useAppParams";
|
||||
import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
|
||||
|
||||
// types
|
||||
|
||||
export type GetCommentsResponse = ApiPaginated<UserComment>;
|
||||
|
||||
const useGetComments = (id: string | number | undefined, page: Ref<number>) => {
|
||||
|
||||
const useGetComments = (id: string | number | undefined) => {
|
||||
// state
|
||||
|
||||
const { $axios: axios } = useNuxtApp();
|
||||
|
||||
const { page } = useAppParams();
|
||||
|
||||
// methods
|
||||
|
||||
const handleGetComments = async () => {
|
||||
const { data } = await axios.get<GetCommentsResponse>(`${API_ENDPOINTS.product.comments}/${id}`);
|
||||
const { data } = await axios.get<GetCommentsResponse>(`${API_ENDPOINTS.product.comments}/${id}`, {
|
||||
params: {
|
||||
offset: Number(page.value) * 8 - 8,
|
||||
limit: 8,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEYS.comments, id, page],
|
||||
queryFn: () => handleGetComments()
|
||||
queryFn: () => handleGetComments(),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// imports
|
||||
|
||||
import { useMutation } from "@tanstack/vue-query";
|
||||
import { API_ENDPOINTS } from "~/constants";
|
||||
|
||||
// types
|
||||
|
||||
export type RateProductRequest = {
|
||||
rating: number;
|
||||
};
|
||||
|
||||
const useRateProduct = (slug: string | undefined) => {
|
||||
// state
|
||||
|
||||
const { $axios: axios } = useNuxtApp();
|
||||
|
||||
// methods
|
||||
|
||||
const handleRateProduct = async (variables: RateProductRequest) => {
|
||||
const { data } = await axios.post(
|
||||
`${API_ENDPOINTS.product.rate}/${slug}/rating/`,
|
||||
variables
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (variables: RateProductRequest) => handleRateProduct(variables),
|
||||
});
|
||||
};
|
||||
|
||||
export default useRateProduct;
|
||||
@@ -26,7 +26,7 @@ const useGetAllTickets = () => {
|
||||
const handleGetAllTickets = async () => {
|
||||
const { data } = await axios.get<GetAllTicketsResponse>(API_ENDPOINTS.tickets.get_all, {
|
||||
params: {
|
||||
sort: sort.value ?? "created_at",
|
||||
sort: sort.value ?? "-created_at",
|
||||
filter: status.value,
|
||||
offset: Number(page.value) * 7 - 7,
|
||||
limit: 7,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PRODUCT_RANGE } from "~/constants";
|
||||
export const useAppParams = () => {
|
||||
// state
|
||||
|
||||
const route = useRoute();
|
||||
const { y } = useWindowScroll({ behavior: "smooth" });
|
||||
|
||||
const slug = useRouteParams<string | undefined>("slug");
|
||||
@@ -58,7 +59,9 @@ export const useAppParams = () => {
|
||||
watch(
|
||||
() => page.value,
|
||||
() => {
|
||||
if (route.name !== "product-id") {
|
||||
y.value = 0;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
// composables/usePersianDate.ts
|
||||
import { format, toDate } from "date-fns-jalali";
|
||||
import { format } from "date-fns-jalali";
|
||||
import { faIR } from "date-fns-jalali/locale";
|
||||
import { Jalali } from "jalali-ts";
|
||||
|
||||
export default function usePersianDate() {
|
||||
const formatToPersian = (isoDate: string): string => {
|
||||
try {
|
||||
const date = toDate(new Date(isoDate));
|
||||
const yearStr = isoDate.slice(0, 4);
|
||||
const year = Number(yearStr);
|
||||
|
||||
// jDateTimeField from django_jalali sends Jalali years (13XX/14XX).
|
||||
// Normal DateTimeField sends Gregorian (19XX/20XX). Detect by year
|
||||
// and use jalali-ts to convert when needed — date-fns-jalali's
|
||||
// parseISO doesn't actually do calendar conversion.
|
||||
const date =
|
||||
!Number.isNaN(year) && year < 1700
|
||||
? Jalali.parse(isoDate).date
|
||||
: new Date(isoDate);
|
||||
|
||||
if (isNaN(date.getTime())) return "Invalid date";
|
||||
|
||||
const persianDate = format(date, "yyyy/MM/dd", { locale: faIR });
|
||||
|
||||
|
||||
@@ -1,12 +1,44 @@
|
||||
// composables/usePersianTimeAgo.ts
|
||||
import { formatDistance, toDate } from "date-fns-jalali";
|
||||
import { formatDistance } from "date-fns-jalali";
|
||||
import { faIR } from "date-fns-jalali/locale";
|
||||
import { Jalali } from "jalali-ts";
|
||||
|
||||
export function usePersianTimeAgo(date: Date) {
|
||||
const toGregorianDate = (input: Date | string): Date | null => {
|
||||
if (input instanceof Date) {
|
||||
return isNaN(input.getTime()) ? null : input;
|
||||
}
|
||||
if (typeof input !== "string" || !input) return null;
|
||||
|
||||
const yearStr = input.slice(0, 4);
|
||||
const year = Number(yearStr);
|
||||
|
||||
// jDateTimeField from django_jalali serializes with the Jalali year
|
||||
// (typically 13XX or 14XX). Anything below ~1700 is treated as Jalali
|
||||
// and converted to the equivalent Gregorian moment via jalali-ts.
|
||||
// date-fns-jalali's parseISO can't be used here — it parses the year
|
||||
// numerically without calendar conversion.
|
||||
if (!Number.isNaN(year) && year < 1700) {
|
||||
try {
|
||||
return Jalali.parse(input).date;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const native = new Date(input);
|
||||
return isNaN(native.getTime()) ? null : native;
|
||||
};
|
||||
|
||||
export function usePersianTimeAgo(date: Date | string) {
|
||||
const timeAgo = ref("");
|
||||
|
||||
const updateTimeAgo = () => {
|
||||
timeAgo.value = formatDistance(toDate(date), new Date(), {
|
||||
const parsed = toGregorianDate(date);
|
||||
if (!parsed) {
|
||||
timeAgo.value = "";
|
||||
return;
|
||||
}
|
||||
timeAgo.value = formatDistance(parsed, new Date(), {
|
||||
addSuffix: true,
|
||||
locale: faIR,
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ export const API_ENDPOINTS = {
|
||||
comments: "/products/comments",
|
||||
create_comment: "/products/comments",
|
||||
get: "/products",
|
||||
rate: "/products",
|
||||
save: "/accounts/favorites/toggle",
|
||||
},
|
||||
auth: {
|
||||
@@ -55,6 +56,7 @@ export const API_ENDPOINTS = {
|
||||
},
|
||||
orders: {
|
||||
get_all: "/order/all",
|
||||
get_one: "/order",
|
||||
cart: {
|
||||
download_invoice: "/order/invoice",
|
||||
get_all: "/order/cart",
|
||||
@@ -93,6 +95,7 @@ export const QUERY_KEYS = {
|
||||
tickets: "tickets",
|
||||
ticket: "ticket",
|
||||
orders: "orders",
|
||||
order: "order",
|
||||
cart: "cart",
|
||||
transaction: "transaction",
|
||||
notifications: "notifications",
|
||||
|
||||
@@ -16,7 +16,7 @@ export default defineNuxtConfig({
|
||||
|
||||
app: {
|
||||
pageTransition: {
|
||||
name: "fade",
|
||||
name: "layout-fade",
|
||||
mode: "out-in",
|
||||
},
|
||||
head: {
|
||||
|
||||
@@ -42,7 +42,8 @@
|
||||
"ogl": "^1.0.11",
|
||||
"reka-ui": "^1.0.0-alpha.11",
|
||||
"sanitize-html": "^2.17.3",
|
||||
"swiper": "^11.2.10",
|
||||
"sharp": "^0.34.4",
|
||||
"swiper": "^12.1.4",
|
||||
"universal-cookie": "^7.2.2",
|
||||
"vue": "^3.5.33",
|
||||
"vue-image-zoomer": "^2.4.4",
|
||||
|
||||
@@ -21,9 +21,9 @@ const router = useRouter();
|
||||
const paymentGateways = ref<PaymentGateway[]>([
|
||||
{
|
||||
id: 5,
|
||||
picture: "/img/gateways/zibal.png",
|
||||
title: "زیبال",
|
||||
type: "ZIBAL",
|
||||
picture: "/img/gateways/zarinpal.png",
|
||||
title: "زرین پال",
|
||||
type: "ZARINPAL",
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ definePageMeta({
|
||||
middleware: "check-is-logged-in",
|
||||
|
||||
prevPage: { name: "cart", label: "سبد خرید" },
|
||||
nextPage: { name: "cart-checkout", label: "تسویه حساب", query: "ZIBAL" },
|
||||
nextPage: { name: "cart-checkout", label: "تسویه حساب", query: "ZARINPAL" },
|
||||
});
|
||||
|
||||
// types
|
||||
|
||||
@@ -70,13 +70,12 @@ const contactWays = ref<{ title: string; ways: { type: "text" | "link"; title: s
|
||||
ways: [
|
||||
{
|
||||
type: "link",
|
||||
title: "09026663488",
|
||||
path: "tell:09026663488",
|
||||
title: "021-93111026",
|
||||
path: "tell:02193111026",
|
||||
},
|
||||
{
|
||||
type: "link",
|
||||
title: "09022202311",
|
||||
path: "tell:09022202311",
|
||||
type: "text",
|
||||
title: "ارتباط با پشتیبانی: داخلی ۱",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
useSeoMeta({
|
||||
title: "شرایط و ضوابط پرداخت سفارشات",
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
dir="rtl"
|
||||
class="container py-16 lg:py-20"
|
||||
>
|
||||
<section class="mx-auto max-w-4xl">
|
||||
<header class="mb-8 lg:mb-10">
|
||||
<h1 class="typo-h-5 md:typo-h-4 text-black mb-4">شرایط و ضوابط پرداخت سفارشات</h1>
|
||||
<p class="text-slate-600 leading-8 max-sm:text-sm">
|
||||
مشتریان گرامی، لطفاً پیش از ثبت سفارش، موارد زیر را با دقت مطالعه فرمایید:
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="space-y-5">
|
||||
<section class="rounded-3xl border border-slate-200 bg-white p-5 md:p-7">
|
||||
<h2 class="typo-h-6 text-black mb-3">۱) درگاه رسمی پرداخت</h2>
|
||||
<ul class="list-disc marker:text-slate-400 pr-5 space-y-2 text-slate-700 leading-8 max-sm:text-sm">
|
||||
<li>
|
||||
تمامی پرداختهای مربوط به سفارشات ثبتشده در فروشگاه بزرگ هی ملز، صرفاً از طریق درگاه پرداخت اینترنتی امن متصل به سایت رسمی فروشگاه به نشانی heymlz.com انجام میپذیرد.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="rounded-3xl border border-slate-200 bg-white p-5 md:p-7">
|
||||
<h2 class="typo-h-6 text-black mb-3">۲) روشهای معتبر پرداخت</h2>
|
||||
<ul class="list-disc marker:text-slate-400 pr-5 space-y-2 text-slate-700 leading-8 max-sm:text-sm">
|
||||
<li>
|
||||
فروشگاه هی ملز در حال حاضر هیچگونه پرداخت به صورت کارتبهکارت، واریز مستقیم بانکی، پرداخت نقدی حضوری یا فروش فیزیکی ندارد و تنها مرجع معتبر جهت پرداخت سفارشات، درگاه رسمی سایت میباشد.
|
||||
</li>
|
||||
<li>
|
||||
مسئولیت هرگونه پرداخت خارج از بستر رسمی سایت heymlz.com بر عهده پرداختکننده بوده و فروشگاه هی ملز هیچگونه تعهد یا مسئولیتی نسبت به تراکنشهای انجامشده از روشهای غیررسمی نخواهد داشت.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="rounded-3xl border border-slate-200 bg-white p-5 md:p-7">
|
||||
<h2 class="typo-h-6 text-black mb-3">۳) تایید تراکنش و اختلال بانکی</h2>
|
||||
<ul class="list-disc marker:text-slate-400 pr-5 space-y-2 text-slate-700 leading-8 max-sm:text-sm">
|
||||
<li>
|
||||
ثبت و نهایی شدن سفارش منوط به تأیید موفق تراکنش بانکی توسط درگاه پرداخت و سیستم مالی فروشگاه میباشد. در صورت بروز اختلال بانکی، مبلغ کسرشده مطابق قوانین شبکه بانکی کشور، توسط بانک صادرکننده کارت به حساب مشتری بازگردانده خواهد شد.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="rounded-3xl border border-slate-200 bg-white p-5 md:p-7">
|
||||
<h2 class="typo-h-6 text-black mb-3">۴) تغییرات آتی روشهای پرداخت</h2>
|
||||
<ul class="list-disc marker:text-slate-400 pr-5 space-y-2 text-slate-700 leading-8 max-sm:text-sm">
|
||||
<li>
|
||||
فروشگاه هی ملز این حق را برای خود محفوظ میدارد که در آینده، بر اساس سیاستهای داخلی مجموعه و همکاری با شرکتهای حملونقل، امکان پرداخت در محل یا سایر روشهای پرداخت را به خدمات خود اضافه یا تغییر دهد.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="rounded-3xl border border-slate-200 bg-white p-5 md:p-7">
|
||||
<h2 class="typo-h-6 text-black mb-3">۵) پذیرش شرایط پرداخت</h2>
|
||||
<ul class="list-disc marker:text-slate-400 pr-5 space-y-2 text-slate-700 leading-8 max-sm:text-sm">
|
||||
<li>
|
||||
استفاده از خدمات سایت و ثبت سفارش به منزله مطالعه، آگاهی و پذیرش کامل شرایط و ضوابط پرداخت توسط مشتری می باشد .
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -4,13 +4,15 @@
|
||||
import ChatButton from "~/components/product/ChatBox/ChatButton.vue";
|
||||
import useGetProduct from "~/composables/api/product/useGetProduct";
|
||||
import ProductsSlider from "~/components/global/product-detail/ProductsSlider.vue";
|
||||
import { useAppParams } from "~/composables/global/useAppParams";
|
||||
|
||||
// state
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const id = route.params.id as string | undefined;
|
||||
|
||||
const { page } = useAppParams();
|
||||
|
||||
const { suspense: suspenseProduct, data: product } = useGetProduct(id);
|
||||
|
||||
useSeoMeta({
|
||||
@@ -49,6 +51,12 @@ if (productResponse.isError) {
|
||||
statusMessage: `error : product ${id} prefetch error`,
|
||||
});
|
||||
}
|
||||
|
||||
// lifecycle
|
||||
|
||||
onMounted(() => {
|
||||
page.value = 1;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -56,12 +64,15 @@ if (productResponse.isError) {
|
||||
<ProductHero />
|
||||
<ProductVideo v-model:showChatButton="showChatButton" />
|
||||
<ProductDetails />
|
||||
<div class="py-20">
|
||||
<ProductsSlider
|
||||
title="محصولات مشابه"
|
||||
:products="product!.related_products"
|
||||
iconImage="/img/simulare-products-section.gif"
|
||||
/>
|
||||
<ProductComments />
|
||||
<ChatButton :showChatButton="showChatButton" />
|
||||
</div>
|
||||
<ProductComments :product="product!" />
|
||||
|
||||
<!-- <ChatButton :showChatButton="showChatButton" /> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -45,8 +45,8 @@ useSeoMeta({
|
||||
|
||||
<template>
|
||||
<div class="w-full container flex flex-col">
|
||||
<div class="w-full flex flex-col lg:flex-row justify-end items-end py-[3.5rem] lg:py-[5rem] gap-10 lg:gap-5">
|
||||
<div class="flex flex-col items-center lg:items-start gap-[1rem] lg:gap-[1.5rem] text-black w-full">
|
||||
<div class="w-full flex flex-col lg:flex-row justify-end items-end py-14 lg:py-20 gap-10 lg:gap-5">
|
||||
<div class="flex flex-col items-center lg:items-start gap-4 lg:gap-6 text-black w-full">
|
||||
<div class="flex gap-2 items-center">
|
||||
<NuxtImg
|
||||
src="/img/poducts-list-section.gif"
|
||||
@@ -61,12 +61,12 @@ useSeoMeta({
|
||||
placeholder="جست و جو محصول ..."
|
||||
v-model="search"
|
||||
variant="outlined"
|
||||
class="!rounded-xl w-full lg:w-8/12"
|
||||
class="rounded-xl! w-full lg:w-8/12"
|
||||
>
|
||||
<template #endItem>
|
||||
<div class="flex items-center gap-1">
|
||||
<Icon
|
||||
class="translate-y-[-1px] text-[20px] lg:text-[24px]"
|
||||
class="-translate-y-px text-[20px] lg:text-[24px]"
|
||||
name="ci:search"
|
||||
/>
|
||||
</div>
|
||||
@@ -79,7 +79,7 @@ useSeoMeta({
|
||||
</template>
|
||||
</FilterButton>
|
||||
<template #fallback>
|
||||
<Skeleton class="!size-11 lg:!w-[10.35rem] lg:!h-[3.35rem] shrink-0 !rounded-xl" />
|
||||
<Skeleton class="size-11! lg:w-[10.35rem]! lg:h-[3.35rem]! shrink-0 rounded-xl!" />
|
||||
</template>
|
||||
</Suspense>
|
||||
</div>
|
||||
@@ -98,10 +98,10 @@ useSeoMeta({
|
||||
:key="i"
|
||||
class="w-full"
|
||||
:class="{
|
||||
'!h-[11.9rem] lg:!h-[17.25rem] !rounded-2xl': i == 1,
|
||||
'!h-[1.4rem] lg:!h-[1.5rem] !rounded-sm': [2, 3].includes(i),
|
||||
'!w-1/2 lg:!w-full': i == 2,
|
||||
'lg:!w-1/2': i == 3,
|
||||
'h-[11.9rem]! lg:h-69! rounded-2xl!': i == 1,
|
||||
'h-[1.4rem]! lg:h-6! rounded-sm!': [2, 3].includes(i),
|
||||
'w-1/2! lg:w-full!': i == 2,
|
||||
'lg:w-1/2!': i == 3,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
@@ -112,7 +112,7 @@ useSeoMeta({
|
||||
>
|
||||
<div
|
||||
v-if="!products?.length"
|
||||
class="flex flex-grow w-full"
|
||||
class="flex grow w-full"
|
||||
>
|
||||
<Placeholder
|
||||
title="محصولی یافت نشد :("
|
||||
@@ -122,7 +122,7 @@ useSeoMeta({
|
||||
<ProductsGrid
|
||||
:with-header="false"
|
||||
:products="products!"
|
||||
class="!p-0"
|
||||
class="p-0!"
|
||||
/>
|
||||
<div
|
||||
v-if="data && paginationData && data.count > 15"
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
<script setup lang="ts">
|
||||
import useGetOrder from "~/composables/api/orders/useGetOrder";
|
||||
import useDownloadInvoice from "~/composables/api/orders/useDownloadInvoice";
|
||||
import usePersianDate from "~/composables/global/usePersianDate";
|
||||
|
||||
// meta
|
||||
|
||||
useSeoMeta({
|
||||
title: "پنل کاربری جزئیات سفارش",
|
||||
});
|
||||
|
||||
definePageMeta({
|
||||
middleware: "check-is-logged-in",
|
||||
layout: "profile",
|
||||
});
|
||||
|
||||
// state
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const { formatToPersian } = usePersianDate();
|
||||
|
||||
// computed
|
||||
|
||||
const orderId = computed(() => route.params.id as string);
|
||||
|
||||
// queries
|
||||
|
||||
const { data: order, isLoading: orderIsLoading } = useGetOrder(orderId);
|
||||
|
||||
const { downloadFn, downloadIsLoading } = useDownloadInvoice(orderId.value);
|
||||
|
||||
// computed
|
||||
|
||||
const statusVariant = computed(() => {
|
||||
const status = order.value?.status;
|
||||
if (!status) return "neutral";
|
||||
if (["ADMIN_PENDING", "PENDING"].includes(status)) return "warning";
|
||||
if (["POSTED", "RECEIVED"].includes(status)) return "success";
|
||||
if (["CANCELED", "REFUND", "REFUNDED"].includes(status)) return "danger";
|
||||
return "neutral";
|
||||
});
|
||||
|
||||
const statusClass = computed(() => {
|
||||
return {
|
||||
warning: "text-warning-600 bg-warning-100 border-warning-600",
|
||||
success: "text-success-600 bg-success-100 border-success-600",
|
||||
danger: "text-danger-600 bg-danger-100 border-danger-600",
|
||||
neutral: "text-slate-600 bg-slate-100 border-slate-300",
|
||||
}[statusVariant.value];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full flex flex-col gap-5">
|
||||
<ProfilePageTitle
|
||||
:title="`جزئیات سفارش ${order?.order_id ? `${order.order_id}#` : ''}`"
|
||||
icon="ci:bi-cart"
|
||||
/>
|
||||
|
||||
<div class="w-full flex flex-col gap-5 lg:px-5">
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||
<NuxtLink :to="{ name: 'profile-purchases-and-orders' }">
|
||||
<Button
|
||||
end-icon="ci:bi-arrow-left"
|
||||
size="md"
|
||||
class="rounded-full"
|
||||
>
|
||||
<span class="whitespace-pre">بازگشت به سفارشات</span>
|
||||
</Button>
|
||||
</NuxtLink>
|
||||
|
||||
<Button
|
||||
v-if="order?.is_paid"
|
||||
@click="!downloadIsLoading ? downloadFn() : undefined"
|
||||
:disabled="downloadIsLoading"
|
||||
end-icon="ci:bi-download"
|
||||
size="md"
|
||||
class="rounded-full"
|
||||
>
|
||||
<span class="whitespace-pre">دانلود فاکتور</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="orderIsLoading"
|
||||
class="w-full grid grid-cols-1 lg:grid-cols-3 gap-5"
|
||||
>
|
||||
<Skeleton class="!w-full !h-40 !rounded-xl lg:col-span-2" />
|
||||
<Skeleton class="!w-full !h-40 !rounded-xl" />
|
||||
<Skeleton class="!w-full !h-60 !rounded-xl lg:col-span-2" />
|
||||
<Skeleton class="!w-full !h-60 !rounded-xl" />
|
||||
</div>
|
||||
|
||||
<Placeholder
|
||||
v-else-if="!order"
|
||||
class="!w-full !py-[5rem]"
|
||||
icon="ci:bi-cart"
|
||||
title="سفارشی یافت نشد"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="w-full grid grid-cols-1 lg:grid-cols-3 gap-5"
|
||||
>
|
||||
<div
|
||||
class="lg:col-span-2 w-full flex flex-col gap-4 p-5 border border-slate-200 rounded-xl bg-slate-50"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-3">
|
||||
<Icon
|
||||
name="ci:bi-cart"
|
||||
class="**:fill-black"
|
||||
size="20"
|
||||
/>
|
||||
<span class="text-sm font-semibold">اطلاعات سفارش</span>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-full py-1.5 px-3 text-xs border"
|
||||
:class="statusClass"
|
||||
>
|
||||
{{ order.verbose_status ?? "--" }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs lg:text-sm">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-dynamic-secondary">شماره سفارش:</span>
|
||||
<span class="font-medium text-cyan-600">{{ order.order_id ? `${order.order_id}#` : "--" }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-dynamic-secondary">تاریخ ثبت:</span>
|
||||
<span class="font-medium">
|
||||
{{ order.created_at ? formatToPersian(order.created_at) : "--" }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-dynamic-secondary">تعداد اقلام:</span>
|
||||
<span class="font-medium">{{ order.count ?? "--" }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-dynamic-secondary">وضعیت پرداخت:</span>
|
||||
<span class="font-medium">
|
||||
{{ order.is_paid ? "پرداخت شده" : "پرداخت نشده" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="w-full flex flex-col gap-4 p-5 border border-slate-200 rounded-xl bg-slate-50"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Icon
|
||||
name="ci:bi-map"
|
||||
class="**:fill-black"
|
||||
size="20"
|
||||
/>
|
||||
<span class="text-sm font-semibold">آدرس تحویل</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="order.address"
|
||||
class="flex flex-col gap-2 text-xs lg:text-sm"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-dynamic-secondary">گیرنده:</span>
|
||||
<span class="font-medium">{{ order.address.name ?? "--" }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-dynamic-secondary">شماره تماس:</span>
|
||||
<span class="font-medium">{{ order.address.phone ?? "--" }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-dynamic-secondary">استان / شهر:</span>
|
||||
<span class="font-medium">
|
||||
{{ order.address.province ?? "--" }} / {{ order.address.city ?? "--" }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-dynamic-secondary">کد پستی:</span>
|
||||
<span class="font-medium">{{ order.address.postal_code ?? "--" }}</span>
|
||||
</div>
|
||||
<div class="text-dynamic-secondary leading-[180%]">
|
||||
{{ order.address.address ?? "--" }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="text-xs text-dynamic-secondary"
|
||||
>
|
||||
آدرسی ثبت نشده است
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="lg:col-span-2 w-full flex flex-col gap-4 p-5 border border-slate-200 rounded-xl bg-slate-50"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Icon
|
||||
name="ci:bi-box"
|
||||
class="**:fill-black"
|
||||
size="20"
|
||||
/>
|
||||
<span class="text-sm font-semibold">اقلام سفارش</span>
|
||||
</div>
|
||||
|
||||
<Placeholder
|
||||
v-if="!order.items?.length"
|
||||
class="!w-full !py-10"
|
||||
icon="ci:bi-box"
|
||||
title="کالایی در این سفارش ثبت نشده"
|
||||
/>
|
||||
|
||||
<ul
|
||||
v-else
|
||||
class="w-full flex flex-col divide-y divide-slate-200"
|
||||
>
|
||||
<li
|
||||
v-for="item in order.items"
|
||||
:key="item.id"
|
||||
class="w-full flex items-center gap-3 py-4"
|
||||
>
|
||||
<NuxtImg
|
||||
v-if="item.product?.image"
|
||||
:src="item.product.image"
|
||||
loading="lazy"
|
||||
fetch-priority="low"
|
||||
class="size-16 lg:size-20 rounded-100 border border-slate-200 object-cover"
|
||||
/>
|
||||
<div class="flex-1 flex flex-col gap-1">
|
||||
<NuxtLink
|
||||
v-if="item.product?.slug"
|
||||
:to="`/product/${item.product.slug}`"
|
||||
class="text-xs lg:text-sm font-semibold line-clamp-2 hover:text-cyan-600 transition-colors"
|
||||
>
|
||||
{{ item.product.title ?? "--" }}
|
||||
</NuxtLink>
|
||||
<span
|
||||
v-else
|
||||
class="text-xs lg:text-sm font-semibold line-clamp-2"
|
||||
>
|
||||
{{ item.product?.title ?? "--" }}
|
||||
</span>
|
||||
<span class="text-xs text-dynamic-secondary">
|
||||
تعداد: {{ item.quantity }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-1 text-xs lg:text-sm">
|
||||
<span class="font-medium whitespace-pre">{{ item.final_price }}</span>
|
||||
<span
|
||||
v-if="item.discount_percent && item.discount_percent > 0"
|
||||
class="text-dynamic-secondary line-through whitespace-pre"
|
||||
>
|
||||
{{ item.price }}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="w-full flex flex-col gap-4 p-5 border border-slate-200 rounded-xl bg-slate-50 h-fit"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Icon
|
||||
name="ci:bi-tag"
|
||||
class="**:fill-black"
|
||||
size="20"
|
||||
/>
|
||||
<span class="text-sm font-semibold">خلاصه فاکتور</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 text-xs lg:text-sm">
|
||||
<div
|
||||
v-if="order.cart_total"
|
||||
class="flex items-center justify-between gap-2 text-slate-800"
|
||||
>
|
||||
<span>جمع سبد:</span>
|
||||
<span>{{ order.cart_total }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="order.discount_amount"
|
||||
class="flex items-center justify-between gap-2 text-red-700"
|
||||
>
|
||||
<span>تخفیف:</span>
|
||||
<span>{{ order.discount_amount }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="order.special_discount_total"
|
||||
class="flex items-center justify-between gap-2 text-green-700"
|
||||
>
|
||||
<span>تخفیف ویژه:</span>
|
||||
<span>{{ order.special_discount_total }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="order.tax"
|
||||
class="flex items-center justify-between gap-2 text-slate-800"
|
||||
>
|
||||
<span>مالیات:</span>
|
||||
<span>{{ order.tax }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 pt-3 border-t border-slate-200 text-slate-900 font-semibold"
|
||||
>
|
||||
<span>مبلغ نهایی:</span>
|
||||
<span>{{ order.final_price ?? "--" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -26,11 +26,11 @@ const tableHeads = ref(["دسته بندی", "موضوع", "تاریخ ایجا
|
||||
const sortFilters = ref([
|
||||
{
|
||||
title: "جدید ترین",
|
||||
value: "created_at",
|
||||
value: "-created_at",
|
||||
},
|
||||
{
|
||||
title: "قدیمی ترین",
|
||||
value: "-created_at",
|
||||
value: "created_at",
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -76,8 +76,8 @@ const paginationData = computed(() => {
|
||||
// methods
|
||||
|
||||
const clearFilters = () => {
|
||||
sort.value = "";
|
||||
status.value = "";
|
||||
sort.value = undefined;
|
||||
status.value = undefined;
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ const handleUploadAttachment = (file: File) => {
|
||||
},
|
||||
});
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -161,7 +161,7 @@ const handleSubmit = async () => {
|
||||
},
|
||||
});
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -225,7 +225,7 @@ const handleSubmit = async () => {
|
||||
</DataField>
|
||||
<DataField
|
||||
id="orders"
|
||||
:required="true"
|
||||
:required="false"
|
||||
label="خرید یا سفارش"
|
||||
>
|
||||
<Select
|
||||
@@ -245,6 +245,16 @@ const handleSubmit = async () => {
|
||||
|
||||
<template #content>
|
||||
<SelectGroup>
|
||||
<SelectItem
|
||||
class="text-xs leading-none w-full rounded-sm py-5 flex items-center justify-between h-[25px] px-[12px] shrink-0 relative select-none data-[disabled]:pointer-events-none data-[highlighted]:outline-none data-[highlighted]:bg-slate-300 data-[highlighted]:text-black"
|
||||
:value="undefined"
|
||||
>
|
||||
<SelectItemText
|
||||
class="w-full text-end font-iran-yekan-x text-sm flex items-center justify-between"
|
||||
>
|
||||
<span class="font-iran-yekan-x text-sm"> هیچ کدام </span>
|
||||
</SelectItemText>
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
v-for="(order, index) in orders?.results"
|
||||
:key="index"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
useSeoMeta({
|
||||
title: "شرایط و ضوابط ارسال سفارشات",
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
dir="rtl"
|
||||
class="container py-16 lg:py-20"
|
||||
>
|
||||
<section class="mx-auto max-w-4xl">
|
||||
<header class="mb-8 lg:mb-10">
|
||||
<h1 class="typo-h-5 md:typo-h-4 text-black mb-4">شرایط و ضوابط ارسال سفارشات</h1>
|
||||
<p class="text-slate-600 leading-8 max-sm:text-sm">
|
||||
مشتریان گرامی، لطفاً پیش از ثبت سفارش، موارد زیر را با دقت مطالعه فرمایید:
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="space-y-5">
|
||||
<section class="rounded-3xl border border-slate-200 bg-white p-5 md:p-7">
|
||||
<h2 class="typo-h-6 text-black mb-3">۱) روش ارسال سفارشات</h2>
|
||||
<ul class="list-disc marker:text-slate-400 pr-5 space-y-2 text-slate-700 leading-8 max-sm:text-sm">
|
||||
<li>فروشگاه بزرگ هی ملز در حال حاضر سفارشات ثبتشده را از طریق شرکت حملونقل دکا پست ارسال مینماید.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="rounded-3xl border border-slate-200 bg-white p-5 md:p-7">
|
||||
<h2 class="typo-h-6 text-black mb-3">۲) نحوه پرداخت هزینه ارسال</h2>
|
||||
<ul class="list-disc marker:text-slate-400 pr-5 space-y-2 text-slate-700 leading-8 max-sm:text-sm">
|
||||
<li>روش ارسال سفارشات به صورت «پسکرایه / پرداخت هزینه حمل در محل» بوده و هزینه کرایه مرسوله در زمان تحویل کالا، بر عهده خریدار محترم میباشد.</li>
|
||||
<li>مبلغ پرداختی در زمان ثبت سفارش، صرفاً مربوط به بهای کالا بوده و هزینه ارسال به صورت جداگانه توسط شرکت حملونقل دریافت خواهد شد.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="rounded-3xl border border-slate-200 bg-white p-5 md:p-7">
|
||||
<h2 class="typo-h-6 text-black mb-3">۳) زمان تحویل و مسئولیتها</h2>
|
||||
<ul class="list-disc marker:text-slate-400 pr-5 space-y-2 text-slate-700 leading-8 max-sm:text-sm">
|
||||
<li>
|
||||
زمان تحویل سفارش بر اساس محدوده جغرافیایی، شرایط حملونقل و برنامه توزیع شرکت دکا پست تعیین میگردد و فروشگاه هی ملز مسئولیتی در قبال تأخیرهای ناشی از عملکرد شرکت حملونقل، شرایط جوی، حوادث غیرمترقبه و عوامل خارج از اختیار نخواهد داشت.
|
||||
</li>
|
||||
<li>
|
||||
خریدار موظف است هنگام تحویل سفارش، سلامت ظاهری بستهبندی و مشخصات مرسوله را بررسی نموده و در صورت وجود هرگونه آسیبدیدگی یا مغایرت، موضوع را در حضور مأمور تحویل ثبت و به پشتیبانی فروشگاه اطلاع دهد.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="rounded-3xl border border-slate-200 bg-white p-5 md:p-7">
|
||||
<h2 class="typo-h-6 text-black mb-3">۴) پذیرش قوانین و امکان تغییر</h2>
|
||||
<ul class="list-disc marker:text-slate-400 pr-5 space-y-2 text-slate-700 leading-8 max-sm:text-sm">
|
||||
<li>ثبت سفارش در سایت به منزله مطالعه، آگاهی و پذیرش کامل شرایط و ضوابط ارسال توسط مشتری میباشد.</li>
|
||||
<li>فروشگاه هی ملز این حق را برای خود محفوظ میدارد که در هر زمان نسبت به تغییر روش ارسال، تعرفهها، شرکت حملکننده یا شرایط مربوط به ارسال سفارش اقدام نماید.</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
useSeoMeta({
|
||||
title: "رویه بازگشت کالا و مهلت تست ۷ روزه",
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
dir="rtl"
|
||||
class="container py-16 lg:py-20"
|
||||
>
|
||||
<section class="mx-auto max-w-4xl">
|
||||
<header class="mb-8 lg:mb-10">
|
||||
<h1 class="typo-h-5 md:typo-h-4 text-black mb-4">رویه بازگشت کالا</h1>
|
||||
<p class="text-slate-600 leading-8 max-sm:text-sm">
|
||||
برای بررسی سریعتر درخواست مرجوعی، لطفاً کالا را مطابق راهنمای زیر ارسال کنید. رعایت این موارد باعث میشود فرآیند پشتیبانی و بازگشت وجه یا تعویض کالا بدون تأخیر انجام شود.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="space-y-5">
|
||||
<section class="rounded-3xl border border-slate-200 bg-white p-5 md:p-7">
|
||||
<h2 class="typo-h-6 text-black mb-3">۱) هماهنگی قبل از ارسال</h2>
|
||||
<ul class="list-disc marker:text-slate-400 pr-5 space-y-2 text-slate-700 leading-8 max-sm:text-sm">
|
||||
<li>قبل از هر اقدامی با کارشناسان پشتیبانی خدمات پس از فروش تماس بگیرید.</li>
|
||||
<li>از ارسال کالا بدون هماهنگی قبلی با هی ملز جداً خودداری کنید.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="rounded-3xl border border-slate-200 bg-white p-5 md:p-7">
|
||||
<h2 class="typo-h-6 text-black mb-3">۲) نحوه بستهبندی صحیح کالا</h2>
|
||||
<ul class="list-disc marker:text-slate-400 pr-5 space-y-2 text-slate-700 leading-8 max-sm:text-sm">
|
||||
<li>کالا باید در جعبه یا کارتن اصلی خود، کاملاً سالم و مناسب بستهبندی شود.</li>
|
||||
<li>
|
||||
تمام لوازم جانبی و اقلام همراه مانند کابل، ریموت، باطری، دفترچه راهنما، کارت گارانتی، کارت بیمه، بند و قطعات بستهبندی باید همراه کالا ارسال شوند.
|
||||
</li>
|
||||
<li>
|
||||
در سفارشهای دارای هدیه، برای مرجوعی کامل سفارش لازم است هدیه نیز عودت داده شود؛ در غیر این صورت هزینه مربوطه از مبلغ بازگشتی کسر خواهد شد.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="rounded-3xl border border-slate-200 bg-white p-5 md:p-7">
|
||||
<h2 class="typo-h-6 text-black mb-3">۳) مواردی که باعث رد مرجوعی میشود</h2>
|
||||
<ul class="list-disc marker:text-slate-400 pr-5 space-y-2 text-slate-700 leading-8 max-sm:text-sm">
|
||||
<li>نوشتن توضیحات، آدرس یا برچسب روی جعبه اصلی کالا</li>
|
||||
<li>پارگی یا مخدوش شدن کارتن یا بستهبندی اصلی</li>
|
||||
</ul>
|
||||
<p class="text-slate-700 leading-8 max-sm:text-sm mt-3">
|
||||
در صورت نیاز، توضیحات خود را روی پشت فاکتور خرید یا یک برگه جداگانه بنویسید.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="rounded-3xl border border-slate-200 bg-white p-5 md:p-7">
|
||||
<h2 class="typo-h-6 text-black mb-3">۴) ارسال پستی و اطلاعات تکمیلی</h2>
|
||||
<ul class="list-disc marker:text-slate-400 pr-5 space-y-2 text-slate-700 leading-8 max-sm:text-sm">
|
||||
<li>در صورت ارسال با پست پیشتاز، تصویر رسید پستی را نگه دارید.</li>
|
||||
<li>
|
||||
تصویر یا عکس رسید را به ایمیل پشتیبانی ارسال کنید:
|
||||
<span class="font-medium text-black">npsayna@gmail.com</span>
|
||||
</li>
|
||||
<li>
|
||||
اگر کالا دارای رمز عبور، Apple ID، الگو یا هر نوع قفل نرمافزاری است، قبل از ارسال آن را حذف کنید تا امکان تست توسط کارشناسان وجود داشته باشد.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="rounded-3xl border border-slate-200 bg-white p-5 md:p-7">
|
||||
<h2 class="typo-h-6 text-black mb-3">سرویس مهلت تست ۷ روزه</h2>
|
||||
<ul class="list-disc marker:text-slate-400 pr-5 space-y-2 text-slate-700 leading-8 max-sm:text-sm">
|
||||
<li>کالاهایی که نیاز به تست دارند، تا ۷ روز پس از تحویل فرصت تست دارند.</li>
|
||||
<li>در صورت نقص فنی تاییدشده، کالا مشمول تعویض یا بازگشت وجه است.</li>
|
||||
<li>آسیبهای فیزیکی ناشی از حمل و نقل باید حداکثر تا ۲۴ ساعت اعلام شود.</li>
|
||||
<li>کالا باید در شرایط اولیه و با لوازم جانبی و فاکتور کامل بازگردانده شود.</li>
|
||||
<li>در صورت اتمام موجودی، وجه کالا به خریدار بازگردانده میشود.</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="mt-8 rounded-2xl bg-slate-50 border border-slate-200 p-4 md:p-5">
|
||||
<p class="text-slate-600 leading-8 max-sm:text-sm">
|
||||
با رعایت این مراحل، درخواست بازگشت کالا سریعتر بررسی شده و نتیجه در کوتاهترین زمان به شما اطلاع داده میشود.
|
||||
</p>
|
||||
</footer>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 16 16"><!-- Icon from Bootstrap Icons by The Bootstrap Authors - https://github.com/twbs/icons/blob/main/LICENSE.md --><path fill="currentColor" d="M13.5 1a1.5 1.5 0 1 0 0 3a1.5 1.5 0 0 0 0-3M11 2.5a2.5 2.5 0 1 1 .603 1.628l-6.718 3.12a2.5 2.5 0 0 1 0 1.504l6.718 3.12a2.5 2.5 0 1 1-.488.876l-6.718-3.12a2.5 2.5 0 1 1 0-3.256l6.718-3.12A2.5 2.5 0 0 1 11 2.5m-8.5 4a1.5 1.5 0 1 0 0 3a1.5 1.5 0 0 0 0-3m11 5.5a1.5 1.5 0 1 0 0 3a1.5 1.5 0 0 0 0-3"/></svg>
|
||||
|
After Width: | Height: | Size: 528 B |
Vendored
+25
-1
@@ -101,6 +101,7 @@ declare global {
|
||||
name: string;
|
||||
description: string;
|
||||
rating: number;
|
||||
user_rating: number | null;
|
||||
slug: string;
|
||||
meta_description: string | null;
|
||||
meta_keywords: string | null;
|
||||
@@ -155,7 +156,9 @@ declare global {
|
||||
timestamp: string;
|
||||
show: boolean;
|
||||
product: number;
|
||||
user: number;
|
||||
user: { id: number; first_name: string; last_name: string; profile_photo: string | null; phone: string };
|
||||
title: string;
|
||||
user_rating: number | null;
|
||||
};
|
||||
|
||||
type Category = {
|
||||
@@ -220,6 +223,27 @@ declare global {
|
||||
special_discount_total?: string;
|
||||
};
|
||||
|
||||
type OrderDetailItem = {
|
||||
id: number;
|
||||
product: CartItem["product"];
|
||||
quantity: number;
|
||||
price: string;
|
||||
final_price: string;
|
||||
discount: number;
|
||||
discount_amount: string;
|
||||
special_discount_amount: string | null;
|
||||
discount_percent?: number;
|
||||
};
|
||||
|
||||
type OrderDetail = Order & {
|
||||
items: OrderDetailItem[];
|
||||
address: Address | null;
|
||||
tax: number | string | null;
|
||||
cart_total: number | string | null;
|
||||
discount_code: DiscountCode | null;
|
||||
discount_amount: number | string | null;
|
||||
};
|
||||
|
||||
type DiscountCode = {
|
||||
code: string;
|
||||
percent: number;
|
||||
|
||||
Reference in New Issue
Block a user