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
|
||||||
default_app_config = "azbankgateways.apps.AZIranianBankGatewaysConfig"
|
|
||||||
|
|
||||||
|
__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 django.contrib import admin
|
||||||
from utils.admin import ModelAdmin
|
|
||||||
from .models import Bank
|
from .models import Bank
|
||||||
|
|
||||||
|
|
||||||
class BankAdmin(ModelAdmin):
|
class BankAdmin(admin.ModelAdmin):
|
||||||
fields = [
|
fields = [
|
||||||
"pk",
|
"pk",
|
||||||
"status",
|
"status",
|
||||||
@@ -17,7 +17,6 @@ class BankAdmin(ModelAdmin):
|
|||||||
"bank_choose_identifier",
|
"bank_choose_identifier",
|
||||||
"created_at",
|
"created_at",
|
||||||
"update_at",
|
"update_at",
|
||||||
'order'
|
|
||||||
]
|
]
|
||||||
list_display = [
|
list_display = [
|
||||||
"pk",
|
"pk",
|
||||||
@@ -32,7 +31,6 @@ class BankAdmin(ModelAdmin):
|
|||||||
"bank_choose_identifier",
|
"bank_choose_identifier",
|
||||||
"created_at",
|
"created_at",
|
||||||
"update_at",
|
"update_at",
|
||||||
'order'
|
|
||||||
]
|
]
|
||||||
list_filter = [
|
list_filter = [
|
||||||
"status",
|
"status",
|
||||||
@@ -66,7 +64,6 @@ class BankAdmin(ModelAdmin):
|
|||||||
"extra_information",
|
"extra_information",
|
||||||
"created_at",
|
"created_at",
|
||||||
"update_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
|
This package exposes bank gateway classes.
|
||||||
from .bmi import BMI # noqa
|
|
||||||
from .idpay import IDPay # noqa
|
NOTE:
|
||||||
from .mellat import Mellat # noqa
|
`from .banks import BaseBank` **must appear first** to avoid circular-import
|
||||||
from .sep import SEP # noqa
|
issues. Other classes depend on `BaseBank`, so importing it earlier prevents
|
||||||
from .zarinpal import Zarinpal # noqa
|
initialization-order problems.
|
||||||
from .zibal import Zibal # noqa
|
"""
|
||||||
|
|
||||||
|
# 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(),
|
"payer_mobile": self.get_mobile_number(),
|
||||||
"callback_url": self._get_gateway_callback_url(),
|
"callback_url": self._get_gateway_callback_url(),
|
||||||
}
|
}
|
||||||
|
data.update(self.get_custom_data())
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def prepare_pay(self):
|
def prepare_pay(self):
|
||||||
@@ -70,7 +71,9 @@ class Bahamta(BaseBank):
|
|||||||
if response_json["ok"]:
|
if response_json["ok"]:
|
||||||
# در این سیستم رفرنس برای ذخیره سازی بر نمی گردد!
|
# در این سیستم رفرنس برای ذخیره سازی بر نمی گردد!
|
||||||
token = self.get_tracking_code()
|
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)
|
self._set_reference_number(token)
|
||||||
else:
|
else:
|
||||||
logging.critical("Bahamta gateway reject payment")
|
logging.critical("Bahamta gateway reject payment")
|
||||||
@@ -82,7 +85,7 @@ class Bahamta(BaseBank):
|
|||||||
|
|
||||||
def prepare_verify_from_gateway(self):
|
def prepare_verify_from_gateway(self):
|
||||||
super(Bahamta, self).prepare_verify_from_gateway()
|
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_reference_number(token)
|
||||||
self._set_bank_record()
|
self._set_bank_record()
|
||||||
|
|
||||||
@@ -109,7 +112,7 @@ class Bahamta(BaseBank):
|
|||||||
super(Bahamta, self).verify(transaction_code)
|
super(Bahamta, self).verify(transaction_code)
|
||||||
data = self.get_verify_data()
|
data = self.get_verify_data()
|
||||||
response_json = self._send_data(self._verify_api_url, 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)
|
self._set_payment_status(PaymentStatus.COMPLETE)
|
||||||
extra_information = json.dumps(response_json.get("result", {}))
|
extra_information = json.dumps(response_json.get("result", {}))
|
||||||
self._bank.extra_information = extra_information
|
self._bank.extra_information = extra_information
|
||||||
@@ -121,7 +124,7 @@ class Bahamta(BaseBank):
|
|||||||
def _send_data(self, api, data):
|
def _send_data(self, api, data):
|
||||||
try:
|
try:
|
||||||
url = append_querystring(api, data)
|
url = append_querystring(api, data)
|
||||||
response = requests.get(url, timeout=5)
|
response = requests.get(url, timeout=self.get_timeout())
|
||||||
except requests.Timeout:
|
except requests.Timeout:
|
||||||
logging.exception("Bahamta time out gateway {}".format(data))
|
logging.exception("Bahamta time out gateway {}".format(data))
|
||||||
raise BankGatewayConnectionError()
|
raise BankGatewayConnectionError()
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import uuid
|
|||||||
from urllib import parse
|
from urllib import parse
|
||||||
|
|
||||||
import six
|
import six
|
||||||
|
from django.conf import settings as django_settings
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from azbankgateways.utils import append_querystring, build_full_url
|
||||||
|
|
||||||
from .. import default_settings as settings
|
from .. import default_settings as settings
|
||||||
from ..exceptions import (
|
from ..exceptions import (
|
||||||
AmountDoesNotSupport,
|
AmountDoesNotSupport,
|
||||||
@@ -18,7 +21,6 @@ from ..exceptions import (
|
|||||||
SafeSettingsEnabled,
|
SafeSettingsEnabled,
|
||||||
)
|
)
|
||||||
from ..models import Bank, CurrencyEnum, PaymentStatus
|
from ..models import Bank, CurrencyEnum, PaymentStatus
|
||||||
from ..utils import append_querystring
|
|
||||||
|
|
||||||
|
|
||||||
# TODO: handle and expire record after 15 minutes
|
# TODO: handle and expire record after 15 minutes
|
||||||
@@ -41,8 +43,12 @@ class BaseBank:
|
|||||||
def __init__(self, identifier: str, **kwargs):
|
def __init__(self, identifier: str, **kwargs):
|
||||||
self.identifier = identifier
|
self.identifier = identifier
|
||||||
self.default_setting_kwargs = kwargs
|
self.default_setting_kwargs = kwargs
|
||||||
|
self._custom_data: dict = {}
|
||||||
self.set_default_settings()
|
self.set_default_settings()
|
||||||
|
|
||||||
|
def _is_strict_origin_policy_enabled(self):
|
||||||
|
return django_settings.SECURE_REFERRER_POLICY == 'strict-origin-when-cross-origin'
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def set_default_settings(self):
|
def set_default_settings(self):
|
||||||
"""default setting, like fetch merchant code, terminal id and etc"""
|
"""default setting, like fetch merchant code, terminal id and etc"""
|
||||||
@@ -162,6 +168,13 @@ class BaseBank:
|
|||||||
def get_mobile_number(self):
|
def get_mobile_number(self):
|
||||||
return self._mobile_number
|
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):
|
def set_client_callback_url(self, callback_url):
|
||||||
"""ذخیره کال بک از طریق نرم افزار برای بازگردانی کاربر پس از بازگشت درگاه بانک به پکیج و سپس از پکیج به نرم
|
"""ذخیره کال بک از طریق نرم افزار برای بازگردانی کاربر پس از بازگشت درگاه بانک به پکیج و سپس از پکیج به نرم
|
||||||
افزار."""
|
افزار."""
|
||||||
@@ -187,7 +200,10 @@ class BaseBank:
|
|||||||
def _set_bank_record(self):
|
def _set_bank_record(self):
|
||||||
try:
|
try:
|
||||||
self._bank = Bank.objects.get(
|
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()),
|
Q(bank_type=self.get_bank_type()),
|
||||||
)
|
)
|
||||||
logging.debug("Set reference find bank object.")
|
logging.debug("Set reference find bank object.")
|
||||||
@@ -216,7 +232,10 @@ class BaseBank:
|
|||||||
return self._transaction_status_text
|
return self._transaction_status_text
|
||||||
|
|
||||||
def _set_payment_status(self, payment_status):
|
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(
|
logging.debug(
|
||||||
"Payment status is not status suitable.",
|
"Payment status is not status suitable.",
|
||||||
extra={"status": self._bank.status},
|
extra={"status": self._bank.status},
|
||||||
@@ -247,6 +266,10 @@ class BaseBank:
|
|||||||
def get_currency(self):
|
def get_currency(self):
|
||||||
return self._currency
|
return self._currency
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_timeout():
|
||||||
|
return settings.BANK_TIMEOUT
|
||||||
|
|
||||||
def get_gateway_amount(self):
|
def get_gateway_amount(self):
|
||||||
return self._gateway_amount
|
return self._gateway_amount
|
||||||
|
|
||||||
@@ -357,8 +380,8 @@ class BaseBank:
|
|||||||
return redirect_url
|
return redirect_url
|
||||||
|
|
||||||
def _get_gateway_callback_url(self):
|
def _get_gateway_callback_url(self):
|
||||||
url = reverse(settings.CALLBACK_NAMESPACE)
|
|
||||||
if self.get_request():
|
if self.get_request():
|
||||||
|
url = reverse(settings.CALLBACK_NAMESPACE)
|
||||||
url_parts = list(parse.urlparse(url))
|
url_parts = list(parse.urlparse(url))
|
||||||
if not (url_parts[0] and url_parts[1]):
|
if not (url_parts[0] and url_parts[1]):
|
||||||
url = self.get_request().build_absolute_uri(url)
|
url = self.get_request().build_absolute_uri(url)
|
||||||
@@ -366,5 +389,6 @@ class BaseBank:
|
|||||||
query.update({"bank_type": self.get_bank_type()})
|
query.update({"bank_type": self.get_bank_type()})
|
||||||
query.update({"identifier": self.identifier})
|
query.update({"identifier": self.identifier})
|
||||||
url = append_querystring(url, query)
|
url = append_querystring(url, query)
|
||||||
|
else:
|
||||||
|
url = build_full_url(settings.CALLBACK_NAMESPACE)
|
||||||
return url
|
return url
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ class BMI(BaseBank):
|
|||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
super(BMI, self).__init__(**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.set_gateway_currency(CurrencyEnum.IRR)
|
||||||
self._token_api_url = "https://sadad.shaparak.ir/vpg/api/v0/Request/PaymentRequest"
|
self._token_api_url = "https://sadad.shaparak.ir/vpg/api/v0/Request/PaymentRequest"
|
||||||
self._payment_url = "https://sadad.shaparak.ir/VPG/Purchase"
|
self._payment_url = "https://sadad.shaparak.ir/VPG/Purchase"
|
||||||
@@ -54,6 +60,7 @@ class BMI(BaseBank):
|
|||||||
"OrderId": self.get_tracking_code(),
|
"OrderId": self.get_tracking_code(),
|
||||||
"AdditionalData": "oi:%s-ou:%s" % (self.get_tracking_code(), self.get_mobile_number()),
|
"AdditionalData": "oi:%s-ou:%s" % (self.get_tracking_code(), self.get_mobile_number()),
|
||||||
}
|
}
|
||||||
|
data.update(self.get_custom_data())
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def prepare_pay(self):
|
def prepare_pay(self):
|
||||||
@@ -63,7 +70,7 @@ class BMI(BaseBank):
|
|||||||
super(BMI, self).pay()
|
super(BMI, self).pay()
|
||||||
data = self.get_pay_data()
|
data = self.get_pay_data()
|
||||||
response_json = self._send_data(self._token_api_url, 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"]
|
token = response_json["Token"]
|
||||||
self._set_reference_number(token)
|
self._set_reference_number(token)
|
||||||
else:
|
else:
|
||||||
@@ -99,10 +106,11 @@ class BMI(BaseBank):
|
|||||||
super(BMI, self).verify(transaction_code)
|
super(BMI, self).verify(transaction_code)
|
||||||
data = self.get_verify_data()
|
data = self.get_verify_data()
|
||||||
response_json = self._send_data(self._verify_api_url, 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)
|
self._set_payment_status(PaymentStatus.COMPLETE)
|
||||||
extra_information = (
|
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.extra_information = extra_information
|
||||||
self._bank.save()
|
self._bank.save()
|
||||||
@@ -113,10 +121,13 @@ class BMI(BaseBank):
|
|||||||
def prepare_verify_from_gateway(self):
|
def prepare_verify_from_gateway(self):
|
||||||
super(BMI, self).prepare_verify_from_gateway()
|
super(BMI, self).prepare_verify_from_gateway()
|
||||||
request = self.get_request()
|
request = self.get_request()
|
||||||
for method in ["POST", "GET", "data", "PUT"]:
|
method_data = getattr(request, "POST", {})
|
||||||
token = getattr(request, method, {}).get("token", None)
|
token = None
|
||||||
if token:
|
for key, value in method_data.items():
|
||||||
|
if key.lower() == "token":
|
||||||
|
token = value
|
||||||
break
|
break
|
||||||
|
|
||||||
if not token:
|
if not token:
|
||||||
raise BankGatewayStateInvalid
|
raise BankGatewayStateInvalid
|
||||||
self._set_reference_number(token)
|
self._set_reference_number(token)
|
||||||
@@ -142,7 +153,7 @@ class BMI(BaseBank):
|
|||||||
|
|
||||||
def _send_data(self, api, data):
|
def _send_data(self, api, data):
|
||||||
try:
|
try:
|
||||||
response = requests.post(api, json=data, timeout=5)
|
response = requests.post(api, json=data, timeout=self.get_timeout())
|
||||||
except requests.Timeout:
|
except requests.Timeout:
|
||||||
logging.exception("BMI time out gateway {}".format(data))
|
logging.exception("BMI time out gateway {}".format(data))
|
||||||
raise BankGatewayConnectionError()
|
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(),
|
"callBackUrl": self._get_gateway_callback_url(),
|
||||||
"payerId": 0,
|
"payerId": 0,
|
||||||
}
|
}
|
||||||
|
data.update(self.get_custom_data())
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def prepare_pay(self):
|
def prepare_pay(self):
|
||||||
@@ -163,9 +164,7 @@ class Mellat(BaseBank):
|
|||||||
status_text = "Payment ID is incorrect"
|
status_text = "Payment ID is incorrect"
|
||||||
elif response == "414":
|
elif response == "414":
|
||||||
status_text = "The organization issuing the bill is invalid"
|
status_text = "The organization issuing the bill is invalid"
|
||||||
elif response == "415":
|
elif response in ["415", "416"]:
|
||||||
status_text = "The working session has ended"
|
|
||||||
elif response == "416":
|
|
||||||
status_text = "The working session has ended"
|
status_text = "The working session has ended"
|
||||||
elif response == "417":
|
elif response == "417":
|
||||||
status_text = "Payer ID is invalid"
|
status_text = "Payer ID is invalid"
|
||||||
@@ -187,12 +186,12 @@ class Mellat(BaseBank):
|
|||||||
def prepare_verify_from_gateway(self):
|
def prepare_verify_from_gateway(self):
|
||||||
super(Mellat, self).prepare_verify_from_gateway()
|
super(Mellat, self).prepare_verify_from_gateway()
|
||||||
post = self.get_request().POST
|
post = self.get_request().POST
|
||||||
token = post.get("RefId", None)
|
token = post.get("RefId")
|
||||||
if not token:
|
if not token:
|
||||||
return
|
return
|
||||||
self._set_reference_number(token)
|
self._set_reference_number(token)
|
||||||
self._set_bank_record()
|
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()
|
self._bank.save()
|
||||||
|
|
||||||
def verify_from_gateway(self, request):
|
def verify_from_gateway(self, request):
|
||||||
@@ -250,7 +249,7 @@ class Mellat(BaseBank):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_client():
|
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)
|
client = Client("https://bpm.shaparak.ir/pgwchannel/services/pgw?wsdl", transport=transport)
|
||||||
return client
|
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 logging
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from zeep import Client, Transport
|
|
||||||
|
|
||||||
from azbankgateways.banks import BaseBank
|
from azbankgateways.banks import BaseBank
|
||||||
from azbankgateways.exceptions import BankGatewayConnectionError, SettingDoesNotExist
|
from azbankgateways.exceptions import BankGatewayConnectionError, SettingDoesNotExist
|
||||||
@@ -16,10 +15,16 @@ class SEP(BaseBank):
|
|||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
super(SEP, self).__init__(**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.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._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):
|
def get_bank_type(self):
|
||||||
return BankType.SEP
|
return BankType.SEP
|
||||||
@@ -32,14 +37,14 @@ class SEP(BaseBank):
|
|||||||
|
|
||||||
def get_pay_data(self):
|
def get_pay_data(self):
|
||||||
data = {
|
data = {
|
||||||
"Action": "Token",
|
"action": "Token",
|
||||||
"Amount": self.get_gateway_amount(),
|
"Amount": self.get_gateway_amount(),
|
||||||
"Wage": 0,
|
|
||||||
"TerminalId": self._merchant_code,
|
"TerminalId": self._merchant_code,
|
||||||
"ResNum": self.get_tracking_code(),
|
"ResNum": self.get_tracking_code(),
|
||||||
"RedirectURL": self._get_gateway_callback_url(),
|
"RedirectURL": self._get_gateway_callback_url(),
|
||||||
"CellNumber": self.get_mobile_number(),
|
"CellNumber": self.get_mobile_number(),
|
||||||
}
|
}
|
||||||
|
data.update(self.get_custom_data())
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def prepare_pay(self):
|
def prepare_pay(self):
|
||||||
@@ -80,15 +85,15 @@ class SEP(BaseBank):
|
|||||||
def prepare_verify_from_gateway(self):
|
def prepare_verify_from_gateway(self):
|
||||||
super(SEP, self).prepare_verify_from_gateway()
|
super(SEP, self).prepare_verify_from_gateway()
|
||||||
request = self.get_request()
|
request = self.get_request()
|
||||||
tracking_code = request.GET.get("ResNum", None)
|
tracking_code = request.GET.get("ResNum")
|
||||||
token = request.GET.get("Token", None)
|
token = request.GET.get("Token")
|
||||||
self._set_tracking_code(tracking_code)
|
self._set_tracking_code(tracking_code)
|
||||||
self._set_bank_record()
|
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:
|
if request.GET.get("State", "NOK") == "OK" and ref_num:
|
||||||
self._set_reference_number(ref_num)
|
self._set_reference_number(ref_num)
|
||||||
self._bank.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.extra_information = extra_information
|
||||||
self._bank.save()
|
self._bank.save()
|
||||||
|
|
||||||
@@ -101,8 +106,7 @@ class SEP(BaseBank):
|
|||||||
|
|
||||||
def get_verify_data(self):
|
def get_verify_data(self):
|
||||||
super(SEP, self).get_verify_data()
|
super(SEP, self).get_verify_data()
|
||||||
data = self.get_reference_number(), self._merchant_code
|
return {"RefNum": self.get_reference_number(), "TerminalNumber": self._merchant_code}
|
||||||
return data
|
|
||||||
|
|
||||||
def prepare_verify(self, tracking_code):
|
def prepare_verify(self, tracking_code):
|
||||||
super(SEP, self).prepare_verify(tracking_code)
|
super(SEP, self).prepare_verify(tracking_code)
|
||||||
@@ -110,9 +114,8 @@ class SEP(BaseBank):
|
|||||||
def verify(self, transaction_code):
|
def verify(self, transaction_code):
|
||||||
super(SEP, self).verify(transaction_code)
|
super(SEP, self).verify(transaction_code)
|
||||||
data = self.get_verify_data()
|
data = self.get_verify_data()
|
||||||
client = self._get_client(self._verify_api_url)
|
result = self._send_data(api=self._verify_api_url, data=data)
|
||||||
result = client.service.verifyTransaction(*data)
|
if result.get('ResultCode') == 0:
|
||||||
if result == self.get_gateway_amount():
|
|
||||||
self._set_payment_status(PaymentStatus.COMPLETE)
|
self._set_payment_status(PaymentStatus.COMPLETE)
|
||||||
else:
|
else:
|
||||||
self._set_payment_status(PaymentStatus.CANCEL_BY_USER)
|
self._set_payment_status(PaymentStatus.CANCEL_BY_USER)
|
||||||
@@ -120,7 +123,7 @@ class SEP(BaseBank):
|
|||||||
|
|
||||||
def _send_data(self, api, data):
|
def _send_data(self, api, data):
|
||||||
try:
|
try:
|
||||||
response = requests.post(api, json=data, timeout=5)
|
response = requests.post(api, json=data, timeout=self.get_timeout())
|
||||||
except requests.Timeout:
|
except requests.Timeout:
|
||||||
logging.exception("SEP time out gateway {}".format(data))
|
logging.exception("SEP time out gateway {}".format(data))
|
||||||
raise BankGatewayConnectionError()
|
raise BankGatewayConnectionError()
|
||||||
@@ -131,16 +134,3 @@ class SEP(BaseBank):
|
|||||||
response_json = get_json(response)
|
response_json = get_json(response)
|
||||||
self._set_transaction_status_text(response_json.get("errorDesc"))
|
self._set_transaction_status_text(response_json.get("errorDesc"))
|
||||||
return response_json
|
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
|
import logging
|
||||||
|
|
||||||
from zeep import Client, Transport
|
import requests
|
||||||
|
|
||||||
from azbankgateways.banks import BaseBank
|
from azbankgateways.banks import BaseBank
|
||||||
from azbankgateways.exceptions import SettingDoesNotExist
|
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.models import BankType, CurrencyEnum, PaymentStatus
|
||||||
|
from azbankgateways.utils import get_json
|
||||||
|
|
||||||
|
|
||||||
class Zarinpal(BaseBank):
|
class Zarinpal(BaseBank):
|
||||||
@@ -16,8 +20,12 @@ class Zarinpal(BaseBank):
|
|||||||
kwargs.setdefault("SANDBOX", 0)
|
kwargs.setdefault("SANDBOX", 0)
|
||||||
super(Zarinpal, self).__init__(**kwargs)
|
super(Zarinpal, self).__init__(**kwargs)
|
||||||
self.set_gateway_currency(CurrencyEnum.IRT)
|
self.set_gateway_currency(CurrencyEnum.IRT)
|
||||||
self._payment_url = "https://www.zarinpal.com/pg/StartPay/{}/ZarinGate"
|
self._payment_type = 'payment'
|
||||||
self._sandbox_url = "https://sandbox.zarinpal.com/pg/StartPay/{}/ZarinGate"
|
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):
|
def get_bank_type(self):
|
||||||
return BankType.ZARINPAL
|
return BankType.ZARINPAL
|
||||||
@@ -37,9 +45,7 @@ class Zarinpal(BaseBank):
|
|||||||
return 1000
|
return 1000
|
||||||
|
|
||||||
def _get_gateway_payment_url_parameter(self):
|
def _get_gateway_payment_url_parameter(self):
|
||||||
if self._sandbox:
|
return self._startpay_url + "{}".format(self.get_reference_number())
|
||||||
return self._sandbox_url.format(self.get_reference_number())
|
|
||||||
return self._payment_url.format(self.get_reference_number())
|
|
||||||
|
|
||||||
def _get_gateway_payment_parameter(self):
|
def _get_gateway_payment_parameter(self):
|
||||||
return {}
|
return {}
|
||||||
@@ -54,14 +60,19 @@ class Zarinpal(BaseBank):
|
|||||||
def get_pay_data(self):
|
def get_pay_data(self):
|
||||||
description = "خرید با شماره پیگیری - {}".format(self.get_tracking_code())
|
description = "خرید با شماره پیگیری - {}".format(self.get_tracking_code())
|
||||||
|
|
||||||
return {
|
data = {
|
||||||
"Description": description,
|
"description": description,
|
||||||
"MerchantID": self._merchant_code,
|
"merchant_id": self._merchant_code,
|
||||||
"Amount": self.get_gateway_amount(),
|
"amount": self.get_gateway_amount(),
|
||||||
"Email": None,
|
"currency": self.get_gateway_currency(),
|
||||||
"Mobile": self.get_mobile_number(),
|
"metadata": {},
|
||||||
"CallbackURL": self._get_gateway_callback_url(),
|
"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):
|
def prepare_pay(self):
|
||||||
super(Zarinpal, self).prepare_pay()
|
super(Zarinpal, self).prepare_pay()
|
||||||
@@ -69,10 +80,9 @@ class Zarinpal(BaseBank):
|
|||||||
def pay(self):
|
def pay(self):
|
||||||
super(Zarinpal, self).pay()
|
super(Zarinpal, self).pay()
|
||||||
data = self.get_pay_data()
|
data = self.get_pay_data()
|
||||||
client = self._get_client()
|
result = self._send_data(api=self._payment_url, data=data)
|
||||||
result = client.service.PaymentRequest(**data)
|
if result['data']:
|
||||||
if result.Status == 100:
|
token = result['data']['authority']
|
||||||
token = result.Authority
|
|
||||||
self._set_reference_number(token)
|
self._set_reference_number(token)
|
||||||
else:
|
else:
|
||||||
logging.critical("Zarinpal gateway reject payment")
|
logging.critical("Zarinpal gateway reject payment")
|
||||||
@@ -84,7 +94,7 @@ class Zarinpal(BaseBank):
|
|||||||
|
|
||||||
def prepare_verify_from_gateway(self):
|
def prepare_verify_from_gateway(self):
|
||||||
super(Zarinpal, self).prepare_verify_from_gateway()
|
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_reference_number(token)
|
||||||
self._set_bank_record()
|
self._set_bank_record()
|
||||||
|
|
||||||
@@ -98,9 +108,9 @@ class Zarinpal(BaseBank):
|
|||||||
def get_verify_data(self):
|
def get_verify_data(self):
|
||||||
super(Zarinpal, self).get_verify_data()
|
super(Zarinpal, self).get_verify_data()
|
||||||
return {
|
return {
|
||||||
"MerchantID": self._merchant_code,
|
"merchant_id": self._merchant_code,
|
||||||
"Authority": self.get_reference_number(),
|
"authority": self.get_reference_number(),
|
||||||
"Amount": self.get_gateway_amount(),
|
"amount": self.get_gateway_amount(),
|
||||||
}
|
}
|
||||||
|
|
||||||
def prepare_verify(self, tracking_code):
|
def prepare_verify(self, tracking_code):
|
||||||
@@ -109,27 +119,26 @@ class Zarinpal(BaseBank):
|
|||||||
def verify(self, transaction_code):
|
def verify(self, transaction_code):
|
||||||
super(Zarinpal, self).verify(transaction_code)
|
super(Zarinpal, self).verify(transaction_code)
|
||||||
data = self.get_verify_data()
|
data = self.get_verify_data()
|
||||||
client = self._get_client(timeout=10)
|
result = self._send_data(api=self._verify_url, data=data)
|
||||||
try:
|
if result['data'] and result['data']['code'] in [100, 101]:
|
||||||
result = client.service.PaymentVerification(**data)
|
|
||||||
if result.Status in [100, 101]:
|
|
||||||
self._set_payment_status(PaymentStatus.COMPLETE)
|
self._set_payment_status(PaymentStatus.COMPLETE)
|
||||||
else:
|
else:
|
||||||
self._set_payment_status(PaymentStatus.CANCEL_BY_USER)
|
self._set_payment_status(PaymentStatus.CANCEL_BY_USER)
|
||||||
logging.debug("Zarinpal gateway unapprove payment")
|
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):
|
def _send_data(self, api, data):
|
||||||
transport = Transport(timeout=timeout, operation_timeout=timeout)
|
try:
|
||||||
if self._sandbox:
|
response = requests.post(api, json=data, timeout=self.get_timeout())
|
||||||
return Client(
|
except requests.Timeout:
|
||||||
"https://sandbox.zarinpal.com/pg/services/WebGate/wsdl",
|
logging.exception("ZARINPAL time out gateway {}".format(data))
|
||||||
transport=transport,
|
raise BankGatewayConnectionError()
|
||||||
)
|
except requests.ConnectionError:
|
||||||
|
logging.exception("ZARINPAL time out gateway {}".format(data))
|
||||||
|
raise BankGatewayConnectionError()
|
||||||
|
|
||||||
return Client(
|
response_json = get_json(response)
|
||||||
"https://www.zarinpal.com/pg/services/WebGate/wsdl",
|
if response_json['data']:
|
||||||
transport=transport,
|
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(),
|
"orderId": self.get_tracking_code(),
|
||||||
"mobile": self.get_mobile_number(),
|
"mobile": self.get_mobile_number(),
|
||||||
}
|
}
|
||||||
|
data.update(self.get_custom_data())
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def prepare_pay(self):
|
def prepare_pay(self):
|
||||||
@@ -77,7 +78,7 @@ class Zibal(BaseBank):
|
|||||||
|
|
||||||
def prepare_verify_from_gateway(self):
|
def prepare_verify_from_gateway(self):
|
||||||
super(Zibal, self).prepare_verify_from_gateway()
|
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_reference_number(token)
|
||||||
self._set_bank_record()
|
self._set_bank_record()
|
||||||
|
|
||||||
@@ -114,7 +115,7 @@ class Zibal(BaseBank):
|
|||||||
|
|
||||||
def _send_data(self, api, data):
|
def _send_data(self, api, data):
|
||||||
try:
|
try:
|
||||||
response = requests.post(api, json=data, timeout=5)
|
response = requests.post(api, json=data, timeout=self.get_timeout())
|
||||||
except requests.Timeout:
|
except requests.Timeout:
|
||||||
logging.exception("Zibal time out gateway {}".format(data))
|
logging.exception("Zibal time out gateway {}".format(data))
|
||||||
raise BankGatewayConnectionError()
|
raise BankGatewayConnectionError()
|
||||||
|
|||||||
Regular → Executable
+3
-2
@@ -12,17 +12,18 @@ BANK_CLASS = getattr(
|
|||||||
"BMI": "azbankgateways.banks.BMI",
|
"BMI": "azbankgateways.banks.BMI",
|
||||||
"SEP": "azbankgateways.banks.SEP",
|
"SEP": "azbankgateways.banks.SEP",
|
||||||
"ZARINPAL": "azbankgateways.banks.Zarinpal",
|
"ZARINPAL": "azbankgateways.banks.Zarinpal",
|
||||||
"IDPAY": "azbankgateways.banks.IDPay",
|
|
||||||
"ZIBAL": "azbankgateways.banks.Zibal",
|
"ZIBAL": "azbankgateways.banks.Zibal",
|
||||||
"BAHAMTA": "azbankgateways.banks.Bahamta",
|
"BAHAMTA": "azbankgateways.banks.Bahamta",
|
||||||
"MELLAT": "azbankgateways.banks.Mellat",
|
"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", {})
|
_AZ_IRANIAN_BANK_GATEWAYS = getattr(settings, "AZ_IRANIAN_BANK_GATEWAYS", {})
|
||||||
BANK_PRIORITIES = _AZ_IRANIAN_BANK_GATEWAYS.get("BANK_PRIORITIES", [])
|
BANK_PRIORITIES = _AZ_IRANIAN_BANK_GATEWAYS.get("BANK_PRIORITIES", [])
|
||||||
BANK_GATEWAYS = _AZ_IRANIAN_BANK_GATEWAYS.get("GATEWAYS", {})
|
BANK_GATEWAYS = _AZ_IRANIAN_BANK_GATEWAYS.get("GATEWAYS", {})
|
||||||
BANK_DEFAULT = _AZ_IRANIAN_BANK_GATEWAYS.get("DEFAULT", "BMI")
|
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 = _AZ_IRANIAN_BANK_GATEWAYS.get(
|
||||||
"SETTING_VALUE_READER_CLASS", "azbankgateways.readers.DefaultReader"
|
"SETTING_VALUE_READER_CLASS", "azbankgateways.readers.DefaultReader"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ from .exceptions import ( # noqa
|
|||||||
AmountDoesNotSupport,
|
AmountDoesNotSupport,
|
||||||
AZBankGatewaysException,
|
AZBankGatewaysException,
|
||||||
BankGatewayConnectionError,
|
BankGatewayConnectionError,
|
||||||
|
BankGatewayRejectPayment,
|
||||||
BankGatewayStateInvalid,
|
BankGatewayStateInvalid,
|
||||||
BankGatewayTokenExpired,
|
BankGatewayTokenExpired,
|
||||||
BankGatewayUnclear,
|
BankGatewayUnclear,
|
||||||
CurrencyDoesNotSupport,
|
CurrencyDoesNotSupport,
|
||||||
SettingDoesNotExist,
|
|
||||||
SafeSettingsEnabled,
|
SafeSettingsEnabled,
|
||||||
|
SettingDoesNotExist,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -82,10 +82,6 @@ msgstr ""
|
|||||||
msgid "Zarinpal"
|
msgid "Zarinpal"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: models/enum.py:10
|
|
||||||
msgid "IDPay"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: models/enum.py:11
|
#: models/enum.py:11
|
||||||
msgid "Zibal"
|
msgid "Zibal"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|||||||
@@ -90,10 +90,6 @@ msgstr "بانک سامان"
|
|||||||
msgid "Zarinpal"
|
msgid "Zarinpal"
|
||||||
msgstr "زرین پال"
|
msgstr "زرین پال"
|
||||||
|
|
||||||
#: models/enum.py:10
|
|
||||||
msgid "IDPay"
|
|
||||||
msgstr "آی دی پی"
|
|
||||||
|
|
||||||
#: models/enum.py:11
|
#: models/enum.py:11
|
||||||
msgid "Zibal"
|
msgid "Zibal"
|
||||||
msgstr "زیبال"
|
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
|
import datetime
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from .enum import BankType, PaymentStatus
|
from .enum import BankType, PaymentStatus
|
||||||
@@ -23,19 +24,28 @@ class BankManager(models.Manager):
|
|||||||
return self.get_queryset().active()
|
return self.get_queryset().active()
|
||||||
|
|
||||||
def update_expire_records(self):
|
def update_expire_records(self):
|
||||||
|
now = timezone.now()
|
||||||
|
cutoff = now - datetime.timedelta(minutes=15)
|
||||||
|
|
||||||
count = (
|
count = (
|
||||||
self.active()
|
self.active()
|
||||||
.filter(
|
.filter(
|
||||||
status=PaymentStatus.RETURN_FROM_BANK,
|
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)
|
.update(status=PaymentStatus.EXPIRE_VERIFY_PAYMENT)
|
||||||
)
|
)
|
||||||
|
|
||||||
count = count + self.active().filter(
|
count = count + self.active().filter(
|
||||||
status=PaymentStatus.REDIRECT_TO_BANK,
|
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)
|
).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
|
return count
|
||||||
|
|
||||||
def filter_return_from_bank(self):
|
def filter_return_from_bank(self):
|
||||||
|
|||||||
Regular → Executable
+2
-2
@@ -6,11 +6,11 @@ class BankType(models.TextChoices):
|
|||||||
BMI = "BMI", _("BMI")
|
BMI = "BMI", _("BMI")
|
||||||
SEP = "SEP", _("SEP")
|
SEP = "SEP", _("SEP")
|
||||||
ZARINPAL = "ZARINPAL", _("Zarinpal")
|
ZARINPAL = "ZARINPAL", _("Zarinpal")
|
||||||
IDPAY = "IDPAY", _("IDPay")
|
|
||||||
ZIBAL = "ZIBAL", _("Zibal")
|
ZIBAL = "ZIBAL", _("Zibal")
|
||||||
BAHAMTA = "BAHAMTA", _("Bahamta")
|
BAHAMTA = "BAHAMTA", _("Bahamta")
|
||||||
MELLAT = "MELLAT", _("Mellat")
|
MELLAT = "MELLAT", _("Mellat")
|
||||||
PAYV1 = "PAYV1", _("PayV1")
|
IRANDARGAH = "IRANDARGAH", _("IranDargah")
|
||||||
|
ASANPARDAKHT = "ASANPARDAKHT", _("AsanPardakht")
|
||||||
|
|
||||||
|
|
||||||
class CurrencyEnum(models.TextChoices):
|
class CurrencyEnum(models.TextChoices):
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import json
|
import json
|
||||||
from urllib import parse
|
from urllib import parse
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
from azbankgateways.types import DictQuerystring
|
from azbankgateways.types import DictQuerystring
|
||||||
|
|
||||||
|
|
||||||
@@ -33,3 +36,28 @@ def split_to_dict_querystring(url: str) -> DictQuerystring:
|
|||||||
url_parts[5] = ""
|
url_parts[5] = ""
|
||||||
|
|
||||||
return parse.urlunparse(url_parts), query
|
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_TIMEZONE = "UTC"
|
||||||
CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler'
|
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
|
from celery.schedules import crontab
|
||||||
|
|
||||||
CELERY_BEAT_SCHEDULE = {
|
CELERY_BEAT_SCHEDULE = {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from django.shortcuts import redirect
|
|||||||
from .permissons import ShopOrderAdminPermission
|
from .permissons import ShopOrderAdminPermission
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
|
from azbankgateways.models.enum import PaymentStatus
|
||||||
|
|
||||||
class OrderItemModelInline(StackedInline):
|
class OrderItemModelInline(StackedInline):
|
||||||
model = OrderItemModel
|
model = OrderItemModel
|
||||||
@@ -271,6 +272,22 @@ class OrderAdmin(ModelAdmin, ImportExportModelAdmin):
|
|||||||
logging.debug("This record is verify now.", extra={"pk": bank_record.pk})
|
logging.debug("This record is verify now.", extra={"pk": bank_record.pk})
|
||||||
elif bank_record.order and not bank_record.order.is_paid:
|
elif bank_record.order and not bank_record.order.is_paid:
|
||||||
bank_record.order.rollback_stock()
|
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"با موفقیت اپدیت شد")
|
messages.success(request, f"با موفقیت اپدیت شد")
|
||||||
return redirect("admin:order_ordermodel_changelist")
|
return redirect("admin:order_ordermodel_changelist")
|
||||||
|
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ class OrderListSerializer(serializers.ModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = OrderModel
|
model = OrderModel
|
||||||
fields = ['created_at', 'status', "images", "count",
|
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']
|
read_only_fields = ['count', 'images', 'order_id', 'verbose_status']
|
||||||
|
|
||||||
def get_verbose_status(self, obj):
|
def get_verbose_status(self, obj):
|
||||||
@@ -172,12 +172,46 @@ class OrderListSerializer(serializers.ModelSerializer):
|
|||||||
return filter(lambda x: x is not None, image_list)
|
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):
|
class OrderGetSerializer(serializers.ModelSerializer):
|
||||||
count = serializers.SerializerMethodField()
|
count = serializers.SerializerMethodField()
|
||||||
images = serializers.SerializerMethodField()
|
images = serializers.SerializerMethodField()
|
||||||
order_id = serializers.SerializerMethodField()
|
order_id = serializers.SerializerMethodField()
|
||||||
verbose_status = serializers.SerializerMethodField()
|
verbose_status = serializers.SerializerMethodField()
|
||||||
items = OrderItemSerailzier(many=True)
|
items = OrderItemDetailSerializer(many=True)
|
||||||
address = UserAddressSerializer()
|
address = UserAddressSerializer()
|
||||||
discount_code = DiscountCodeSerializer()
|
discount_code = DiscountCodeSerializer()
|
||||||
|
|
||||||
|
|||||||
+55
-4
@@ -4,33 +4,84 @@ from azbankgateways import (
|
|||||||
models as bank_models,
|
models as bank_models,
|
||||||
default_settings as settings,
|
default_settings as settings,
|
||||||
)
|
)
|
||||||
|
from azbankgateways.models.enum import PaymentStatus
|
||||||
from .models import OrderModel
|
from .models import OrderModel
|
||||||
from account.models import PushSubscription
|
from account.models import PushSubscription
|
||||||
import ghasedak_sms
|
import ghasedak_sms
|
||||||
from product.models import ProductImageModel
|
from product.models import ProductImageModel
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def udpate_bank_status():
|
def udpate_bank_status():
|
||||||
factory = bankfactories.BankFactory()
|
factory = bankfactories.BankFactory()
|
||||||
|
|
||||||
|
# ۱. بروزرسانی رکوردهای منقضی در یک تراکنش جداگانه
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
bank_models.Bank.objects.update_expire_records()
|
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():
|
for item in bank_models.Bank.objects.filter_return_from_bank():
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
bank = factory.create(
|
bank = factory.create(
|
||||||
bank_type=item.bank_type, identifier=item.bank_choose_identifier
|
bank_type=item.bank_type, identifier=item.bank_choose_identifier
|
||||||
)
|
)
|
||||||
bank.verify(item.tracking_code)
|
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:
|
if bank_record.is_success and bank_record.order:
|
||||||
bank_record.order.cart.clear_cart()
|
bank_record.order.cart.clear_cart()
|
||||||
bank_record.order.is_paid = True
|
bank_record.order.is_paid = True
|
||||||
bank_record.order.save()
|
bank_record.order.save(update_fields=['is_paid'])
|
||||||
logging.debug("This record is verify now.", extra={"pk": bank_record.pk})
|
|
||||||
elif bank_record.order:
|
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
|
order = bank_record.order
|
||||||
|
|
||||||
|
# بررسی مجدد شرط برای اطمینان در لحظه قفل شدن
|
||||||
|
if order and not order.is_paid and not order.is_stock_rolled_back:
|
||||||
order.rollback_stock()
|
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
|
@shared_task
|
||||||
|
|||||||
@@ -214,11 +214,13 @@ class OrderlistView(APIView):
|
|||||||
status_filter = request.query_params.get("status", None)
|
status_filter = request.query_params.get("status", None)
|
||||||
sort = request.query_params.get('sort', None)
|
sort = request.query_params.get('sort', None)
|
||||||
if status_filter in ['ADMIN_PENDING', 'PENDING', 'POSTED', 'RECEIVED', 'CANCELED', 'REFUNDED']:
|
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:
|
||||||
if sort not in ['created_at', '-created_at', 'final_price', '-final_price']:
|
if sort not in ['created_at', '-created_at', 'final_price', '-final_price']:
|
||||||
return Response({'detail': 'پارامتر sort اشتباه است'}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'detail': 'پارامتر sort اشتباه است'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
orders = orders.order_by(sort)
|
orders = orders.order_by(sort)
|
||||||
|
else:
|
||||||
|
orders = orders.order_by('-created_at')
|
||||||
paginator = self.pagination_class()
|
paginator = self.pagination_class()
|
||||||
paginated_orders = paginator.paginate_queryset(orders, request)
|
paginated_orders = paginator.paginate_queryset(orders, request)
|
||||||
orders_ser = self.serializer_class(
|
orders_ser = self.serializer_class(
|
||||||
@@ -544,6 +546,9 @@ class UserOrderInvoiceView(APIView):
|
|||||||
bank_detail = Bank.objects.get(tracking_code=order_id)
|
bank_detail = Bank.objects.get(tracking_code=order_id)
|
||||||
order = bank_detail.order
|
order = bank_detail.order
|
||||||
order_id = order.id
|
order_id = order.id
|
||||||
|
except Bank.DoesNotExist:
|
||||||
|
try:
|
||||||
|
order = OrderModel.objects.get(id=order_id)
|
||||||
except OrderModel.DoesNotExist:
|
except OrderModel.DoesNotExist:
|
||||||
return Response(
|
return Response(
|
||||||
{'detail': 'سفارش مورد نظر یافت نشد'},
|
{'detail': 'سفارش مورد نظر یافت نشد'},
|
||||||
|
|||||||
@@ -414,7 +414,7 @@ class ProductModelAdmin(ProductAdminPermission, ModelAdmin, ImportExportModelAdm
|
|||||||
# compressed_fields = True
|
# compressed_fields = True
|
||||||
warn_unsaved_form = True
|
warn_unsaved_form = True
|
||||||
# list_per_page = 2
|
# 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', ]
|
list_display = ['display_image', 'shop__shop_name', 'view', 'rating', 'category', 'created_at' ,'show_in_website', ]
|
||||||
fieldsets = (
|
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"],}),
|
('فیلد های اصلی', {'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()} تنوع محصول اپدیت شد")
|
messages.success(request, f"قیمت {ProductVariant.objects.all().count()} تنوع محصول اپدیت شد")
|
||||||
return redirect("admin:product_productmodel_changelist")
|
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):
|
def bulk_update_subcategory_action(self, request, queryset):
|
||||||
"""اکشن برای تغییر دستهبندی چند محصول همزمان"""
|
"""اکشن برای تغییر دستهبندی چند محصول همزمان"""
|
||||||
|
|
||||||
@@ -535,7 +586,7 @@ class ProductModelAdmin(ProductAdminPermission, ModelAdmin, ImportExportModelAdm
|
|||||||
)
|
)
|
||||||
|
|
||||||
bulk_update_subcategory_action.short_description = "تغییر دستهبندی محصولات انتخاب شده"
|
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):
|
def get_usd_price(self):
|
||||||
try:
|
try:
|
||||||
api_usd = "https://api.nobitex.ir/v2/orderbook/USDTIRT"
|
api_usd = "https://api.nobitex.ir/v2/orderbook/USDTIRT"
|
||||||
response = requests.get(api_usd)
|
response = requests.get(api_usd, timeout=5)
|
||||||
data = response.json()
|
data = response.json()
|
||||||
price = int(data["lastTradePrice"])
|
price = int(data["lastTradePrice"])
|
||||||
price_in_usd = price / 10.0
|
price_in_usd = price / 10.0
|
||||||
@@ -403,6 +403,10 @@ class ProductVariant(DirtyFieldsMixin, models.Model):
|
|||||||
discount = models.SmallIntegerField(default=0, verbose_name='درصد تخفیف', help_text='این درصد از قیمت نهایی محصول کسر میگردد')
|
discount = models.SmallIntegerField(default=0, verbose_name='درصد تخفیف', help_text='این درصد از قیمت نهایی محصول کسر میگردد')
|
||||||
color = models.CharField(
|
color = models.CharField(
|
||||||
verbose_name='رنگ', max_length=7, blank=True, null=True)
|
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='عکس ها')
|
images = models.ManyToManyField(ProductImageModel, verbose_name='عکس ها')
|
||||||
video = models.FileField(upload_to='product_videos/',
|
video = models.FileField(upload_to='product_videos/',
|
||||||
blank=True, null=True, verbose_name='ویدیو')
|
blank=True, null=True, verbose_name='ویدیو')
|
||||||
|
|||||||
+13
-10
@@ -17,13 +17,16 @@ TOROB_WEBHOOK_MIN_INTERVAL_SECONDS = 3.1
|
|||||||
TOROB_WEBHOOK_MAX_RETRIES = 3
|
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 ""
|
domain = getattr(settings, "DOMAIN", None) or getattr(settings, "API_DOMAIN", None) or ""
|
||||||
if domain.startswith("http://") or domain.startswith("https://"):
|
if domain.startswith("http://") or domain.startswith("https://"):
|
||||||
base = domain.rstrip("/")
|
base = domain.rstrip("/")
|
||||||
else:
|
else:
|
||||||
base = f"https://{domain}".rstrip("/") if domain 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:
|
def _variant_page_unique(product: ProductModel, variant: ProductVariant) -> str:
|
||||||
@@ -124,18 +127,18 @@ def send_torob_product_webhook(product_ids):
|
|||||||
if not product.slug:
|
if not product.slug:
|
||||||
continue
|
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())
|
variants = list(product.variants.all())
|
||||||
if not variants:
|
if not variants:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for variant in variants:
|
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
|
# Validate variant has images before sending to Torob
|
||||||
# Per spec: image_links is required, so skip variants without images
|
# Per spec: image_links is required, so skip variants without images
|
||||||
images = list(variant.images.all())
|
images = list(variant.images.all())
|
||||||
@@ -148,7 +151,7 @@ def send_torob_product_webhook(product_ids):
|
|||||||
|
|
||||||
items.append(
|
items.append(
|
||||||
{
|
{
|
||||||
"page_url": page_url,
|
"page_url": variant_page_url,
|
||||||
"page_unique": _variant_page_unique(product, variant),
|
"page_unique": _variant_page_unique(product, variant),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
from django.conf import settings
|
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]
|
modes = [name for name in ("page_urls", "page_uniques", "page") if name in attrs]
|
||||||
|
|
||||||
if len(modes) != 1:
|
if len(modes) != 1:
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError("invalid request body")
|
||||||
"invalid request body"
|
|
||||||
)
|
|
||||||
|
|
||||||
if "page" in attrs and "sort" not in attrs:
|
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:
|
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
|
return attrs
|
||||||
|
|
||||||
@@ -79,6 +80,18 @@ def _extract_slug_from_url(value: str) -> str | None:
|
|||||||
return path.split("/")[-1]
|
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:
|
def _variant_page_unique(product: ProductModel, variant: ProductVariant) -> str:
|
||||||
return f"{product.pk}_{variant.pk}"
|
return f"{product.pk}_{variant.pk}"
|
||||||
|
|
||||||
@@ -108,13 +121,15 @@ def _absolute_url(request, value: str) -> str:
|
|||||||
return request.build_absolute_uri(value)
|
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()
|
domain = getattr(settings, "DOMAIN", None) or getattr(settings, "API_DOMAIN", None) or request.get_host()
|
||||||
if domain.startswith("http://") or domain.startswith("https://"):
|
if domain.startswith("http://") or domain.startswith("https://"):
|
||||||
base = domain.rstrip("/")
|
base = domain.rstrip("/")
|
||||||
else:
|
else:
|
||||||
base = f"https://{domain}".rstrip("/")
|
base = f"https://{domain}".rstrip("/")
|
||||||
url = f"{base}/product/{product.slug}/"
|
url = f"{base}/product/{product.slug}/"
|
||||||
|
if variant is not None:
|
||||||
|
url = f"{url}?variant={variant.pk}"
|
||||||
# Per spec: page_url max 1500 chars
|
# Per spec: page_url max 1500 chars
|
||||||
return url[:1500]
|
return url[:1500]
|
||||||
|
|
||||||
@@ -139,9 +154,6 @@ def _variant_spec(variant: ProductVariant | None) -> dict:
|
|||||||
if variant.color:
|
if variant.color:
|
||||||
spec.setdefault("color", variant.color)
|
spec.setdefault("color", variant.color)
|
||||||
|
|
||||||
if variant.in_stock is not None:
|
|
||||||
spec.setdefault("in_stock", variant.in_stock)
|
|
||||||
|
|
||||||
return spec
|
return spec
|
||||||
|
|
||||||
|
|
||||||
@@ -212,7 +224,7 @@ def _serialize_variant(request, product: ProductModel, variant: ProductVariant)
|
|||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"page_unique": _variant_page_unique(product, variant),
|
"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),
|
"product_group_id": str(product.pk),
|
||||||
"title": _truncate_text(product.name, 500),
|
"title": _truncate_text(product.name, 500),
|
||||||
"subtitle": _truncate_text(product.meta_description, 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),
|
"category_name": _truncate_text(product.category.name if product.category else None, 200),
|
||||||
"image_links": _product_image_links(request, product, variant),
|
"image_links": _product_image_links(request, product, variant),
|
||||||
"spec": _variant_spec(variant),
|
"spec": _variant_spec(variant),
|
||||||
"guarantee": None,
|
"guarantee": _truncate_text(variant.guarantee, 200),
|
||||||
"short_desc": _truncate_text(product.description, 500),
|
"short_desc": _truncate_text(product.description, 500),
|
||||||
"date_added": _variant_date_added(product, variant),
|
"date_added": _variant_date_added(product, variant),
|
||||||
"date_updated": _variant_date_updated(product, variant),
|
"date_updated": _variant_date_updated(product, variant),
|
||||||
"seller_name": product.shop.shop_name if product.shop else None,
|
# "seller_name": product.shop.shop_name if product.shop else None,
|
||||||
"seller_city": _truncate_text(product.shop.city if product.shop else None, 200),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if old_price is not None and old_price > current_price:
|
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,
|
key=TOROB_PUBLIC_KEY,
|
||||||
algorithms=["EdDSA"],
|
algorithms=["EdDSA"],
|
||||||
audience=_get_hostname_from_request(request),
|
audience=_get_hostname_from_request(request),
|
||||||
|
options={"require": ["exp", "nbf", "aud"]},
|
||||||
)
|
)
|
||||||
logger.debug("Token validated successfully")
|
logger.debug("Token validated successfully")
|
||||||
|
except jwt.MissingRequiredClaimError as exc:
|
||||||
|
logger.warning(f"Missing required JWT claim: {exc}")
|
||||||
|
raise
|
||||||
except jwt.ExpiredSignatureError:
|
except jwt.ExpiredSignatureError:
|
||||||
logger.warning("Token has expired")
|
logger.warning("Token has expired")
|
||||||
raise
|
raise
|
||||||
|
except jwt.ImmatureSignatureError:
|
||||||
|
logger.warning("Token is not yet valid")
|
||||||
|
raise
|
||||||
except jwt.InvalidAudienceError:
|
except jwt.InvalidAudienceError:
|
||||||
logger.warning(f"Audience mismatch for request from {request.get_host()}")
|
logger.warning(f"Audience mismatch for request from {request.get_host()}")
|
||||||
raise
|
raise
|
||||||
@@ -284,15 +302,6 @@ class TorobProductSyncView(APIView):
|
|||||||
permission_classes = []
|
permission_classes = []
|
||||||
|
|
||||||
def post(self, request):
|
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:
|
try:
|
||||||
_validate_torob_token(request)
|
_validate_torob_token(request)
|
||||||
except TorobTokenError as exc:
|
except TorobTokenError as exc:
|
||||||
@@ -338,27 +347,49 @@ class TorobProductSyncView(APIView):
|
|||||||
for product in products
|
for product in products
|
||||||
}
|
}
|
||||||
|
|
||||||
ordered_products = []
|
ordered_lookups: list[tuple[ProductModel, str | None]] = []
|
||||||
for url in requested_urls:
|
for url in requested_urls:
|
||||||
slug = _extract_slug_from_url(url)
|
slug = _extract_slug_from_url(url)
|
||||||
normalized_url = _normalize_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
|
product = product_by_slug.get(slug) if slug else None
|
||||||
if product is None:
|
if product is None:
|
||||||
product = product_by_url.get(normalized_url)
|
product = product_by_url.get(normalized_url)
|
||||||
if product is not None and product not in ordered_products:
|
if product is None:
|
||||||
ordered_products.append(product)
|
continue
|
||||||
|
ordered_lookups.append((product, variant_id))
|
||||||
|
|
||||||
serialized_products = []
|
serialized_products = []
|
||||||
for product in ordered_products:
|
seen: set[str] = set()
|
||||||
|
for product, variant_id in ordered_lookups:
|
||||||
variants = list(product.variants.all())
|
variants = list(product.variants.all())
|
||||||
if not variants:
|
if not variants:
|
||||||
continue
|
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)
|
variants.sort(key=_variant_sort_key)
|
||||||
for variant in variants:
|
for variant in variants:
|
||||||
image_links = _product_image_links(request, product, variant)
|
image_links = _product_image_links(request, product, variant)
|
||||||
# Skip variants without images as per spec requirement
|
# Skip variants without images as per spec requirement
|
||||||
if not image_links:
|
if not image_links:
|
||||||
continue
|
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))
|
serialized_products.append(_serialize_variant(request, product, variant))
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
|
|||||||
+186
-19
@@ -1,3 +1,4 @@
|
|||||||
|
import re
|
||||||
from .models import ProductModel
|
from .models import ProductModel
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
@@ -6,8 +7,8 @@ from .models import *
|
|||||||
from .serializers import *
|
from .serializers import *
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from django.db.models import Q, Value
|
from django.db.models import Q, Value, Case, When, FloatField, F, CharField, Func
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce, Length
|
||||||
from django.contrib.postgres.search import TrigramSimilarity
|
from django.contrib.postgres.search import TrigramSimilarity
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from rest_framework.permissions import IsAuthenticatedOrReadOnly
|
from rest_framework.permissions import IsAuthenticatedOrReadOnly
|
||||||
@@ -21,6 +22,179 @@ from home.models import ShowCaseSlider
|
|||||||
from home.serializers import ShowCaseSliderSerialzier
|
from home.serializers import ShowCaseSliderSerialzier
|
||||||
from order.models import Cart, CartItem
|
from order.models import Cart, CartItem
|
||||||
from django.db.models import Min, Max, Value
|
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):
|
# class APIView(APIView):
|
||||||
# def __init__(self, *args, **kwargs):
|
# def __init__(self, *args, **kwargs):
|
||||||
# super().__init__(*args, **kwargs)
|
# super().__init__(*args, **kwargs)
|
||||||
@@ -324,18 +498,9 @@ class AllProductsView(APIView):
|
|||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
# Search
|
# Search (Persian-aware, with typo tolerance + similar-results fallback)
|
||||||
search_query = request.query_params.get('search')
|
search_query = request.query_params.get('search')
|
||||||
if search_query:
|
products, normalized_query = _apply_product_search(products, search_query)
|
||||||
products = products.annotate(
|
|
||||||
similarity=(
|
|
||||||
TrigramSimilarity('name', search_query) +
|
|
||||||
TrigramSimilarity(
|
|
||||||
Coalesce('description', Value('')),
|
|
||||||
search_query
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).filter(similarity__gt=0.1)
|
|
||||||
|
|
||||||
# Price annotation (IMPORTANT for sorting)
|
# Price annotation (IMPORTANT for sorting)
|
||||||
products = products.annotate(
|
products = products.annotate(
|
||||||
@@ -376,8 +541,10 @@ class AllProductsView(APIView):
|
|||||||
|
|
||||||
elif sort_by in ['price', '-price']:
|
elif sort_by in ['price', '-price']:
|
||||||
products = products.order_by('min_price' if sort_by == 'price' else '-min_price')
|
products = products.order_by('min_price' if sort_by == 'price' else '-min_price')
|
||||||
elif search_query:
|
elif normalized_query:
|
||||||
products = products.order_by('-similarity', 'name')
|
# Tie-break on shorter name: ensures "چای" outranks "چای ساز"
|
||||||
|
# when their bonus-adjusted similarities are close.
|
||||||
|
products = products.order_by('-similarity', Length('norm_name'), 'name')
|
||||||
else:
|
else:
|
||||||
products = products.order_by('name')
|
products = products.order_by('name')
|
||||||
|
|
||||||
@@ -522,11 +689,9 @@ class ShowCaseProductsView(APIView):
|
|||||||
if has_discount:
|
if has_discount:
|
||||||
products = products.filter(variants__discount__gt=0).distinct()
|
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)
|
search_query = request.query_params.get('search', None)
|
||||||
if search_query:
|
products, normalized_query = _apply_product_search(products, search_query)
|
||||||
products = products.filter(Q(name__icontains=search_query) | Q(
|
|
||||||
description__icontains=search_query))
|
|
||||||
|
|
||||||
# Price filters
|
# Price filters
|
||||||
price_gte = request.query_params.get('price_gte', None)
|
price_gte = request.query_params.get('price_gte', None)
|
||||||
@@ -543,6 +708,8 @@ class ShowCaseProductsView(APIView):
|
|||||||
sort_by = request.query_params.get('sort', None)
|
sort_by = request.query_params.get('sort', None)
|
||||||
if sort_by in ['name', '-name', 'created_at', '-created_at']:
|
if sort_by in ['name', '-name', 'created_at', '-created_at']:
|
||||||
products = products.order_by(sort_by)
|
products = products.order_by(sort_by)
|
||||||
|
elif normalized_query:
|
||||||
|
products = products.order_by('-similarity', Length('norm_name'), 'name')
|
||||||
else:
|
else:
|
||||||
products = products.order_by('name')
|
products = products.order_by('name')
|
||||||
|
|
||||||
|
|||||||
@@ -109,11 +109,13 @@ class TicketListView(APIView):
|
|||||||
filter_by = request.query_params.get('filter', None)
|
filter_by = request.query_params.get('filter', None)
|
||||||
sort = request.query_params.get('sort', None)
|
sort = request.query_params.get('sort', None)
|
||||||
if filter_by:
|
if filter_by:
|
||||||
tickets.filter(status=str(filter_by))
|
tickets = tickets.filter(status=str(filter_by))
|
||||||
if sort:
|
if sort:
|
||||||
if sort not in ['created_at', '-created_at']:
|
if sort not in ['created_at', '-created_at']:
|
||||||
return Response({'detail': 'wrong sort paramter'}, status=status.HTTP_400_BAD_REQUEST)
|
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()
|
paginator = self.pagination_class()
|
||||||
paginated_tickets = paginator.paginate_queryset(tickets, request)
|
paginated_tickets = paginator.paginate_queryset(tickets, request)
|
||||||
tickets_ser = self.serializer_class(instance=paginated_tickets, many=True, context={'request': request})
|
tickets_ser = self.serializer_class(instance=paginated_tickets, many=True, context={'request': request})
|
||||||
|
|||||||
+5
-4
@@ -17,8 +17,7 @@ services:
|
|||||||
|
|
||||||
django:
|
django:
|
||||||
container_name: shop_backend
|
container_name: shop_backend
|
||||||
build:
|
image: fix_update_bank:latest
|
||||||
context: ./backend
|
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -108,8 +107,10 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
container_name: hshop_redis
|
container_name: hshop_redis
|
||||||
image: redis:alpine
|
image: redis:alpine
|
||||||
ports:
|
command: >
|
||||||
- "6379:6379"
|
redis-server
|
||||||
|
--maxmemory 512mb
|
||||||
|
--maxmemory-policy volatile-lru
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
restart: always
|
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
|
Zoom animation
|
||||||
*/
|
*/
|
||||||
|
|||||||
+58
-5
@@ -32,7 +32,8 @@
|
|||||||
"ogl": "^1.0.11",
|
"ogl": "^1.0.11",
|
||||||
"reka-ui": "^1.0.0-alpha.11",
|
"reka-ui": "^1.0.0-alpha.11",
|
||||||
"sanitize-html": "^2.17.3",
|
"sanitize-html": "^2.17.3",
|
||||||
"swiper": "^11.2.10",
|
"sharp": "^0.34.4",
|
||||||
|
"swiper": "^12.1.4",
|
||||||
"universal-cookie": "^7.2.2",
|
"universal-cookie": "^7.2.2",
|
||||||
"vue": "^3.5.33",
|
"vue": "^3.5.33",
|
||||||
"vue-image-zoomer": "^2.4.4",
|
"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=="],
|
"@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/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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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/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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||||
|
|
||||||
"lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
"lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||||
|
|||||||
@@ -58,7 +58,10 @@ const formRules = computed(() => {
|
|||||||
},
|
},
|
||||||
postal_code: {
|
postal_code: {
|
||||||
required: helpers.withMessage("فیلد کد پستی الزامی می باشد", required),
|
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: {
|
address: {
|
||||||
required: helpers.withMessage("فیلد آدرس کامل الزامی می باشد", required),
|
required: helpers.withMessage("فیلد آدرس کامل الزامی می باشد", required),
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const { date } = toRefs(props);
|
|||||||
|
|
||||||
// state
|
// state
|
||||||
|
|
||||||
const createdAt = usePersianTimeAgo(new Date(date.value));
|
const createdAt = usePersianTimeAgo(date.value);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -21,14 +21,14 @@
|
|||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4 items-center justify-center relative z-20">
|
<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
|
<img
|
||||||
src="/img/heymlz/heymlz-small-idle.gif"
|
src="/img/heymlz/heymlz-small-idle.gif"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
fetch-priority="low"
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
<div
|
<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"
|
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>
|
<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">
|
<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">
|
<div class="flex items-center gap-4 mt-6 max-lg:justify-center">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="#"
|
to="#"
|
||||||
class="flex-center size-[1.5rem]"
|
class="flex-center size-6"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
name="ci:instagram"
|
name="ci:instagram"
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="#"
|
to="#"
|
||||||
class="flex-center size-[1.5rem]"
|
class="flex-center size-6"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
name="ci:facebook"
|
name="ci:facebook"
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="#"
|
to="#"
|
||||||
class="flex-center size-[1.5rem]"
|
class="flex-center size-6"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
name="ci:tiktok"
|
name="ci:tiktok"
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="#"
|
to="#"
|
||||||
class="flex-center size-[1.5rem]"
|
class="flex-center size-6"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
name="ci:youtube"
|
name="ci:youtube"
|
||||||
@@ -96,11 +96,7 @@
|
|||||||
<a href="tel:02193111026"> 93111026-021 </a>
|
<a href="tel:02193111026"> 93111026-021 </a>
|
||||||
</span>
|
</span>
|
||||||
<br />
|
<br />
|
||||||
<span>
|
<span> ارتباط با پشتیبانی: داخلی ۱ </span>
|
||||||
برای پشتیبانی : داخلی ۱
|
|
||||||
<br />
|
|
||||||
برای مدیریت : داخلی ۴
|
|
||||||
</span>
|
|
||||||
</li>
|
</li>
|
||||||
<li>ایمیل: npsayna@gmail.com</li>
|
<li>ایمیل: npsayna@gmail.com</li>
|
||||||
<li><NuxtLink to="contact-us">تیکت</NuxtLink></li>
|
<li><NuxtLink to="contact-us">تیکت</NuxtLink></li>
|
||||||
@@ -114,6 +110,15 @@
|
|||||||
<li>
|
<li>
|
||||||
<NuxtLink to="product-return"> رویه های بازگرداندن کالا </NuxtLink>
|
<NuxtLink to="product-return"> رویه های بازگرداندن کالا </NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="shipping-methods"> شرایط و روش های ارسال </NuxtLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="test-return"> روشهای تست و مرجوعی </NuxtLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="contact-us">گزارش باگ</NuxtLink>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<NuxtLink to="#"> پرسش های متداول </NuxtLink>
|
<NuxtLink to="#"> پرسش های متداول </NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
@@ -121,7 +126,7 @@
|
|||||||
<NuxtLink to="privacy"> حریم خصوصی </NuxtLink>
|
<NuxtLink to="privacy"> حریم خصوصی </NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<NuxtLink to="contact-us">گزارش باگ</NuxtLink>
|
<NuxtLink to="payment-methods"> شرایط و روش های پرداخت </NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
type Props = {
|
type Props = {
|
||||||
variant?: "solid" | "outlined";
|
variant?: "solid" | "outlined";
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
modelValue: string;
|
modelValue: number | string | undefined;
|
||||||
error?: boolean;
|
error?: boolean;
|
||||||
options?: string[];
|
options?: string[];
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
@@ -13,7 +13,7 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type Emits = {
|
type Emits = {
|
||||||
"update:modelValue": [value: string];
|
"update:modelValue": [value: number | string | undefined];
|
||||||
};
|
};
|
||||||
|
|
||||||
// props
|
// props
|
||||||
@@ -34,7 +34,7 @@ const emit = defineEmits<Emits>();
|
|||||||
// computed
|
// computed
|
||||||
|
|
||||||
const selectedValue = computed({
|
const selectedValue = computed({
|
||||||
get: () => modelValue.value ?? undefined,
|
get: () => modelValue.value,
|
||||||
set: (value: string) => emit("update: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 items-start gap-3">
|
||||||
<div class="flex p-1 items-center justify-center rounded-full bg-success-500">
|
<div class="flex p-1 items-center justify-center rounded-full bg-success-500">
|
||||||
<Icon
|
<Icon
|
||||||
name="ci:check"
|
name="ci:bi-check"
|
||||||
class="size-4 **:stroke-white"
|
class="size-4 **:fill-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1 ">
|
||||||
<span class="typo-label-sm whitespace-nowrap">{{ product?.customer_pickup_title }}</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<span class="typo-p-xs max-sm:hidden">فروشگاه هیملز</span>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ const onSwiper = (swiper: SwiperClass) => {
|
|||||||
<section class="w-full flex flex-col gap-10 md:gap-16 lg:container">
|
<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="w-full flex justify-between items-center max-lg:container">
|
||||||
<div class="flex gap-2 items-center">
|
<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">
|
<span class="text-black typo-h-6 md:typo-h-5 lg:typo-h-4">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</span>
|
</span>
|
||||||
@@ -69,6 +72,7 @@ const onSwiper = (swiper: SwiperClass) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ClientOnly>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<Swiper
|
<Swiper
|
||||||
:slides-per-view="1.5"
|
:slides-per-view="1.5"
|
||||||
@@ -108,6 +112,19 @@ const onSwiper = (swiper: SwiperClass) => {
|
|||||||
</SwiperSlide>
|
</SwiperSlide>
|
||||||
</Swiper>
|
</Swiper>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
|
||||||
// types
|
// types
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
rate: number
|
rate: number;
|
||||||
}
|
haveRate?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
// props
|
// props
|
||||||
|
|
||||||
defineProps<Props>();
|
defineProps<Props>();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex items-center gap-2">
|
<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 }}
|
{{ rate }}
|
||||||
|
</template>
|
||||||
|
<template v-else> ( بدون نظر ) </template>
|
||||||
</span>
|
</span>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<Icon
|
<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>
|
||||||
|
|
||||||
<div class="relative w-full">
|
<div class="relative w-full">
|
||||||
|
<ClientOnly>
|
||||||
<Swiper
|
<Swiper
|
||||||
:slides-per-view="3"
|
:slides-per-view="3"
|
||||||
:space-between="20"
|
:space-between="20"
|
||||||
@@ -111,6 +112,14 @@ const changeSlide = (id: number) => {
|
|||||||
</div>
|
</div>
|
||||||
</SwiperSlide>
|
</SwiperSlide>
|
||||||
</Swiper>
|
</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
|
<div
|
||||||
v-if="slides.length > 3"
|
v-if="slides.length > 3"
|
||||||
|
|||||||
@@ -1,23 +1,33 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
||||||
// type
|
// type
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
title: string,
|
title: string;
|
||||||
date: string,
|
date: string;
|
||||||
username: string,
|
first_name: string;
|
||||||
content: string,
|
last_name: string;
|
||||||
}
|
content: string;
|
||||||
|
rate: number;
|
||||||
|
};
|
||||||
|
|
||||||
// props
|
// props
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
const { date } = toRefs(props);
|
const { date, first_name, last_name } = toRefs(props);
|
||||||
|
|
||||||
// state
|
// state
|
||||||
|
|
||||||
const formattedDate = useDateFormat(date.value, "YYYY/MM/DD");
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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 justify-between items-start w-full max-sm:flex-col gap-4">
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<span class="text-lg font-semibold sm:typo-h-6 text-black">
|
<span class="text-lg font-semibold sm:typo-h-6 text-black">
|
||||||
خیلی محصول خوبی بودددد
|
{{ title }}
|
||||||
</span>
|
</span>
|
||||||
<span class="typo-p-xs sm:typo-p-sm text-slate-500">
|
<span class="typo-p-xs sm:typo-p-sm text-slate-500">
|
||||||
{{ username }}
|
{{ username }}
|
||||||
@@ -33,7 +43,7 @@ const formattedDate = useDateFormat(date.value, "YYYY/MM/DD");
|
|||||||
{{ formattedDate }}
|
{{ formattedDate }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Rating :rate="2"/>
|
<Rating :rate="rate" />
|
||||||
</div>
|
</div>
|
||||||
<div class="typo-p-md">
|
<div class="typo-p-md">
|
||||||
{{ content }}
|
{{ content }}
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ const parallaxStyle = computed(() => {
|
|||||||
fetch-priority="low"
|
fetch-priority="low"
|
||||||
class="group-hover:scale-105 transition-transform duration-200 size-full object-contain absolute inset-0"
|
class="group-hover:scale-105 transition-transform duration-200 size-full object-contain absolute inset-0"
|
||||||
alt="product-background"
|
alt="product-background"
|
||||||
|
:quality="5"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- <div
|
<!-- <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>
|
<span class="text-white typo-h-6 md:typo-h-5 lg:typo-h-4 min-[2000px]:typo-h-2"> دسته بندی ها </span>
|
||||||
</div>
|
</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
|
<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"
|
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="{
|
:style="{
|
||||||
filter: 'drop-shadow(0px 0px 20px rgba(0, 0, 0, 0.4))',
|
filter: 'drop-shadow(0px 0px 20px rgba(0, 0, 0, 0.4))',
|
||||||
@@ -52,6 +56,8 @@ const onSlideChange = (swiper: SwiperClass) => {
|
|||||||
fetch-priority="low"
|
fetch-priority="low"
|
||||||
src="/img/heymlz/heymlz-category-seat.gif"
|
src="/img/heymlz/heymlz-category-seat.gif"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ClientOnly>
|
||||||
<Swiper
|
<Swiper
|
||||||
:loop="true"
|
:loop="true"
|
||||||
:centered-slides="true"
|
:centered-slides="true"
|
||||||
@@ -124,8 +130,22 @@ const onSlideChange = (swiper: SwiperClass) => {
|
|||||||
</SwiperSlide>
|
</SwiperSlide>
|
||||||
</Swiper>
|
</Swiper>
|
||||||
|
|
||||||
|
<template #fallback>
|
||||||
<div
|
<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()"
|
@click="swiper_instance?.slidePrev()"
|
||||||
:style="{
|
:style="{
|
||||||
right: `calc(50% - ${slideWidth / 2}px - 20px)`,
|
right: `calc(50% - ${slideWidth / 2}px - 20px)`,
|
||||||
@@ -139,7 +159,7 @@ const onSlideChange = (swiper: SwiperClass) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="!swiper_instance?.isEnd"
|
v-if="swiper_instance && !swiper_instance?.isEnd"
|
||||||
@click="swiper_instance?.slideNext()"
|
@click="swiper_instance?.slideNext()"
|
||||||
:style="{
|
:style="{
|
||||||
left: `calc(50% - ${slideWidth / 2}px - 20px)`,
|
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 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>
|
||||||
<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
|
<video
|
||||||
src="/video/curtain-red.webm"
|
src="/video/curtain-red.webm"
|
||||||
class="w-full"
|
class="w-full bg-neutral-200"
|
||||||
autoplay
|
autoplay
|
||||||
muted
|
muted
|
||||||
loop
|
loop
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ const initializeGsapAnimation = () => {
|
|||||||
const resetTimelineForMobile = () => {
|
const resetTimelineForMobile = () => {
|
||||||
gsap.to("#header-navbar", {
|
gsap.to("#header-navbar", {
|
||||||
background: "white",
|
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", {
|
gsap.to(".header-navbar-item", {
|
||||||
filter: "invert(0%)",
|
filter: "invert(0%)",
|
||||||
@@ -182,13 +182,13 @@ onUnmounted(() => {
|
|||||||
<div
|
<div
|
||||||
id="header-slider-wrapper"
|
id="header-slider-wrapper"
|
||||||
class="relative"
|
class="relative"
|
||||||
:class="swiper_instance ? '' : 'bg-black min-h-svh'"
|
:class="swiper_instance ? '' : 'min-h-svh bg-black'"
|
||||||
>
|
>
|
||||||
<Swiper
|
<Swiper
|
||||||
ref="observerTarget"
|
ref="observerTarget"
|
||||||
:class="swiper_instance ? '' : 'opacity-0'"
|
:class="swiper_instance ? '' : 'opacity-0'"
|
||||||
:slides-per-view="slidesPerView"
|
:slides-per-view="slidesPerView"
|
||||||
:loop="true"
|
:loop="false"
|
||||||
:centered-slides="true"
|
:centered-slides="true"
|
||||||
:breakpoints="{
|
:breakpoints="{
|
||||||
768: {
|
768: {
|
||||||
@@ -212,7 +212,7 @@ onUnmounted(() => {
|
|||||||
webkit-playsinline
|
webkit-playsinline
|
||||||
class="slide-video absolute inset-0 size-full object-cover brightness-90"
|
class="slide-video absolute inset-0 size-full object-cover brightness-90"
|
||||||
:src="slide.video"
|
:src="slide.video"
|
||||||
poster="/img/test-thumbnail.jpeg"
|
:poster="slide.image!"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ watch(
|
|||||||
} else {
|
} else {
|
||||||
activeSlideVideo.value = "none";
|
activeSlideVideo.value = "none";
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -67,7 +67,7 @@ watch(
|
|||||||
if (clipPercent >= 1 && clipPercent <= 99) {
|
if (clipPercent >= 1 && clipPercent <= 99) {
|
||||||
clipPathPercent.value = clipPercent;
|
clipPathPercent.value = clipPercent;
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -76,11 +76,11 @@ watch(
|
|||||||
<div>
|
<div>
|
||||||
<div class="flex flex-col items-center gap-3 mb-10 lg:mb-16">
|
<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-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>
|
||||||
<div
|
<div
|
||||||
ref="previewContainerEl"
|
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">
|
<Transition name="fade">
|
||||||
<NuxtImg
|
<NuxtImg
|
||||||
@@ -97,7 +97,8 @@ watch(
|
|||||||
muted
|
muted
|
||||||
playsinline
|
playsinline
|
||||||
webkit-playsinline
|
webkit-playsinline
|
||||||
src="/video/vid-3.mp4"
|
loop
|
||||||
|
:src="homeData!.difreance_section.video1"
|
||||||
class="select-none absolute size-full object-cover brightness-[95%]"
|
class="select-none absolute size-full object-cover brightness-[95%]"
|
||||||
/>
|
/>
|
||||||
</Transition>
|
</Transition>
|
||||||
@@ -121,7 +122,8 @@ watch(
|
|||||||
muted
|
muted
|
||||||
playsinline
|
playsinline
|
||||||
webkit-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%]"
|
class="overlay-image select-none absolute object-cover size-full brightness-[95%]"
|
||||||
/>
|
/>
|
||||||
</Transition>
|
</Transition>
|
||||||
@@ -145,7 +147,7 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<div
|
||||||
class="flex flex-col gap-2 text-black transition-opacity"
|
class="flex flex-col gap-2 text-black transition-opacity"
|
||||||
:class="activeSlideVideo === 'right' ? 'opacity-0' : ''"
|
:class="activeSlideVideo === 'right' ? 'opacity-0' : ''"
|
||||||
@@ -174,7 +176,7 @@ watch(
|
|||||||
{{ homeData!.difreance_section.title2 }}
|
{{ homeData!.difreance_section.title2 }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,35 +3,87 @@
|
|||||||
|
|
||||||
import useGetComments from "~/composables/api/product/useGetComments";
|
import useGetComments from "~/composables/api/product/useGetComments";
|
||||||
import useCreateComment from "~/composables/api/product/useCreateComment";
|
import useCreateComment from "~/composables/api/product/useCreateComment";
|
||||||
|
import useRateProduct from "~/composables/api/product/useRateProduct";
|
||||||
import { useAuth } from "~/composables/api/auth/useAuth";
|
import { useAuth } from "~/composables/api/auth/useAuth";
|
||||||
|
import useGetProduct from "~/composables/api/product/useGetProduct";
|
||||||
|
|
||||||
|
// props
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
product: Product;
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
// state
|
// state
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const id = route.params.id as string | undefined;
|
const id = route.params.id as string | undefined;
|
||||||
const page = ref(1);
|
|
||||||
|
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
|
const userTitle = ref("");
|
||||||
const userComment = ref("");
|
const userComment = ref("");
|
||||||
|
const selectedRating = ref(5);
|
||||||
|
|
||||||
const showMoreComments = ref(false);
|
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: 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
|
// methods
|
||||||
|
|
||||||
const submitComment = async () => {
|
const submitComment = async () => {
|
||||||
if (userComment.value.length > 3) {
|
if (!canSubmitComment.value) {
|
||||||
await createComment({
|
return;
|
||||||
content: userComment.value,
|
|
||||||
});
|
|
||||||
|
|
||||||
userComment.value = "";
|
|
||||||
|
|
||||||
await refetchComments();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// computed
|
||||||
@@ -50,22 +102,88 @@ const limitedComments = computed(() => {
|
|||||||
v-if="!!comments"
|
v-if="!!comments"
|
||||||
class="bg-slate-50"
|
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
|
<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>
|
<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">
|
<!-- <div class="flex flex-col gap-2">
|
||||||
<Rating :rate="2" />
|
<Rating :rate="props.product.rating" />
|
||||||
<span class="typo-p-sm"> بر اساس {{ comments?.count }} نظر </span>
|
<span class="typo-p-sm"> بر اساس {{ comments?.count }} نظر </span>
|
||||||
</div>
|
</div> -->
|
||||||
<form
|
<form
|
||||||
@submit.prevent="submitComment"
|
@submit.prevent="submitComment"
|
||||||
class="flex flex-col gap-6"
|
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
|
<textarea
|
||||||
:disabled="!token"
|
: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"
|
v-model="userComment"
|
||||||
placeholder="نظر خود را بنویسید..."
|
placeholder="نظر خود را بنویسید..."
|
||||||
/>
|
/>
|
||||||
@@ -73,10 +191,10 @@ const limitedComments = computed(() => {
|
|||||||
v-if="token"
|
v-if="token"
|
||||||
type="submit"
|
type="submit"
|
||||||
class="rounded-full w-full"
|
class="rounded-full w-full"
|
||||||
:loading="isCreateCommentPending"
|
:loading="isCreateCommentPending || isRateProductPending"
|
||||||
:disabled="isCreateCommentPending"
|
:disabled="isCreateCommentPending || isRateProductPending || !canSubmitComment"
|
||||||
>
|
>
|
||||||
نظر بنویسید
|
ثبت نظر
|
||||||
</Button>
|
</Button>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-else
|
v-else
|
||||||
@@ -86,7 +204,7 @@ const limitedComments = computed(() => {
|
|||||||
type="button"
|
type="button"
|
||||||
class="rounded-full w-full"
|
class="rounded-full w-full"
|
||||||
>
|
>
|
||||||
وارد شوید
|
برای ثبت نظر وارد شوید
|
||||||
</Button>
|
</Button>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</form>
|
</form>
|
||||||
@@ -95,10 +213,12 @@ const limitedComments = computed(() => {
|
|||||||
<Comment
|
<Comment
|
||||||
v-for="comment in limitedComments"
|
v-for="comment in limitedComments"
|
||||||
:key="comment.id"
|
:key="comment.id"
|
||||||
title=""
|
:title="comment.title"
|
||||||
:content="comment.content"
|
:content="comment.content"
|
||||||
:date="comment.timestamp"
|
:date="comment.timestamp"
|
||||||
:username="'منصور مرزبان'"
|
:first_name="comment.user.first_name"
|
||||||
|
:last_name="comment.user.last_name"
|
||||||
|
:rate="comment.user_rating ?? 0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -109,9 +229,10 @@ const limitedComments = computed(() => {
|
|||||||
v-if="showMoreComments"
|
v-if="showMoreComments"
|
||||||
:total="comments.count"
|
:total="comments.count"
|
||||||
:items="comments.results.map((item, i) => ({ type: 'page', value: i }))"
|
:items="comments.results.map((item, i) => ({ type: 'page', value: i }))"
|
||||||
|
:per-page="8"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
v-else
|
v-else-if="comments.count > 3"
|
||||||
type="button"
|
type="button"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@click="showMoreComments = !showMoreComments"
|
@click="showMoreComments = !showMoreComments"
|
||||||
@@ -123,15 +244,15 @@ const limitedComments = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
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
|
<NuxtImg
|
||||||
src="/img/heymlz/heymlz-contact-us.gif"
|
src="/img/heymlz/heymlz-contact-us.gif"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
fetch-priority="low"
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const { selectedVariant } = inject("productVariant") as ProductVariantProvideTyp
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<div class="w-full flex">
|
||||||
<span class="text-black max-lg:hidden typo-h-4 mb-4"> جزئیات محصول </span>
|
<span class="text-black max-lg:hidden typo-h-4 mb-4"> جزئیات محصول </span>
|
||||||
</div>
|
</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 type { ProductVariantProvideType } from "~/pages/product/[id].vue";
|
||||||
import useAddCartItem from "~/composables/api/orders/useAddCartItem";
|
import useAddCartItem from "~/composables/api/orders/useAddCartItem";
|
||||||
import { useAuth } from "~/composables/api/auth/useAuth";
|
import { useAuth } from "~/composables/api/auth/useAuth";
|
||||||
import useSaveProduct from "~/composables/api/product/useSaveProduct";
|
|
||||||
import { QUERY_KEYS } from "~/constants";
|
|
||||||
|
|
||||||
// state
|
// state
|
||||||
|
|
||||||
@@ -14,11 +12,9 @@ const route = useRoute();
|
|||||||
const id = route.params.id as string | undefined;
|
const id = route.params.id as string | undefined;
|
||||||
|
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
const { $queryClient: queryClient } = useNuxtApp();
|
|
||||||
|
|
||||||
const { data: product, refetch: refetchProduct, isFetching: isFetchingPending } = useGetProduct(id);
|
const { data: product, refetch: refetchProduct, isFetching: isFetchingPending } = useGetProduct(id);
|
||||||
const { mutateAsync: addCartItem, isPending: isAddCartItemPending } = useAddCartItem();
|
const { mutateAsync: addCartItem, isPending: isAddCartItemPending } = useAddCartItem();
|
||||||
const { mutateAsync: saveProduct, isPending: isSaveProductPending } = useSaveProduct();
|
|
||||||
|
|
||||||
const selectedVariantId = ref(product.value!.variants[0].id);
|
const selectedVariantId = ref(product.value!.variants[0].id);
|
||||||
const selectedQuantity = ref(1);
|
const selectedQuantity = ref(1);
|
||||||
@@ -40,11 +36,6 @@ const addItemToCart = async () => {
|
|||||||
await refetchProduct();
|
await refetchProduct();
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveProductHandler = async () => {
|
|
||||||
await saveProduct({ product_slug: product.value!.slug });
|
|
||||||
await queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.product] });
|
|
||||||
};
|
|
||||||
|
|
||||||
// watch
|
// watch
|
||||||
|
|
||||||
watch([selectedVariantId, product], ([selectedVariantId, product]) => {
|
watch([selectedVariantId, product], ([selectedVariantId, product]) => {
|
||||||
@@ -61,7 +52,7 @@ watch(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
immediate: true,
|
immediate: true,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -72,36 +63,37 @@ watch(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
immediate: true,
|
immediate: true,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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 flex-col gap-3 lg:hidden">
|
||||||
<div class="flex items-center justify-between w-full">
|
<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
|
<NuxtLink
|
||||||
to="#"
|
to="#"
|
||||||
class="typo-label-sm"
|
class="typo-label-sm"
|
||||||
>
|
>
|
||||||
{{ product!.category.name }}
|
{{ product!.category.name }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<button
|
</div>
|
||||||
@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
|
<div class="flex items-center gap-2">
|
||||||
v-else
|
<ShareButton :product="product!" />
|
||||||
:class="product?.added_to_favorites ? '**:fill-blue-400' : ''"
|
<SaveButton />
|
||||||
:name="product?.added_to_favorites ? 'bi-bookmark-fill' : 'ci:bi-bookmark'"
|
</div>
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<h1 class="typo-h-6 xs:typo-h-5 sm:typo-h-4 lg:typo-h-2">
|
<h1 class="typo-h-6 xs:typo-h-5 sm:typo-h-4 lg:typo-h-2">
|
||||||
{{ product!.name }}
|
{{ product!.name }}
|
||||||
@@ -136,7 +128,10 @@ watch(
|
|||||||
<span class="max-sm:hidden"> تخفیف درصد </span>
|
<span class="max-sm:hidden"> تخفیف درصد </span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Rating :rate="3" />
|
<Rating
|
||||||
|
:rate="product!.rating"
|
||||||
|
:have-rate="product!.rating !== 0"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Slider
|
<Slider
|
||||||
@@ -146,28 +141,28 @@ watch(
|
|||||||
/>
|
/>
|
||||||
<div class="lg:w-1/2 flex flex-col gap-3 mt-12">
|
<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 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
|
<NuxtLink
|
||||||
to="#"
|
to="#"
|
||||||
class="typo-label-sm"
|
class="typo-label-sm"
|
||||||
>
|
>
|
||||||
{{ product!.category.name }}
|
{{ product!.category.name }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<button
|
</div>
|
||||||
@click="saveProductHandler"
|
<div class="flex items-center gap-2">
|
||||||
:disabled="isSaveProductPending || isFetchingPending || !token"
|
<ShareButton :product="product!" />
|
||||||
class="size-10 bg-slate-50 border-slate-200 border rounded-lg flex-center"
|
<SaveButton />
|
||||||
>
|
</div>
|
||||||
<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>
|
||||||
<h1 class="typo-h-4 xl:typo-h-3 max-lg:hidden">
|
<h1 class="typo-h-4 xl:typo-h-3 max-lg:hidden">
|
||||||
{{ product!.name }}
|
{{ product!.name }}
|
||||||
@@ -204,13 +199,15 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Rating
|
<Rating
|
||||||
:rate="3"
|
:rate="product!.rating"
|
||||||
|
:have-rate="product!.rating !== 0"
|
||||||
class="sm:hidden"
|
class="sm:hidden"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Rating
|
<Rating
|
||||||
:rate="3"
|
:rate="product!.rating"
|
||||||
|
:have-rate="product!.rating !== 0"
|
||||||
class="max-sm:hidden"
|
class="max-sm:hidden"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -238,7 +235,7 @@ watch(
|
|||||||
<div class="flex items-center gap-6 flex-wrap">
|
<div class="flex items-center gap-6 flex-wrap">
|
||||||
<ProductVariant
|
<ProductVariant
|
||||||
@click="selectedVariantId = variant.id"
|
@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"
|
:key="variant.id"
|
||||||
:variantDetail="variant"
|
:variantDetail="variant"
|
||||||
:isSelected="selectedVariantId === variant.id"
|
:isSelected="selectedVariantId === variant.id"
|
||||||
@@ -338,8 +335,6 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<InfoCard />
|
<InfoCard />
|
||||||
|
|
||||||
<Share />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ProductDescription
|
<ProductDescription
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// imports
|
// imports
|
||||||
|
|
||||||
import useDownloadInvoice from "~/composables/api/orders/useDownloadInvoice";
|
import useDownloadInvoice from "~/composables/api/orders/useDownloadInvoice";
|
||||||
|
import usePersianDate from "~/composables/global/usePersianDate";
|
||||||
|
|
||||||
// types
|
// types
|
||||||
|
|
||||||
@@ -15,6 +16,10 @@ const props = defineProps<Props>();
|
|||||||
|
|
||||||
const { data } = toRefs(props);
|
const { data } = toRefs(props);
|
||||||
|
|
||||||
|
// state
|
||||||
|
|
||||||
|
const { formatToPersian } = usePersianDate();
|
||||||
|
|
||||||
// queries
|
// queries
|
||||||
|
|
||||||
const { downloadFn, downloadIsLoading } = useDownloadInvoice(String(data.value.id));
|
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}#` : "--" }}
|
{{ data.order_id ? `${data.order_id}#` : "--" }}
|
||||||
</td>
|
</td>
|
||||||
<td class="w-3/12 px-6 py-6 text-xs lg:text-sm font-medium whitespace-pre shrink-0">
|
<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>
|
||||||
<td class="w-2/12 px-6 py-6 text-xs lg:text-sm whitespace-pre shrink-0">
|
<td class="w-2/12 px-6 py-6 text-xs lg:text-sm whitespace-pre shrink-0">
|
||||||
{{ data.count ? data.count : "--" }}
|
{{ data.count ? data.count : "--" }}
|
||||||
@@ -53,10 +58,25 @@ const { downloadFn, downloadIsLoading } = useDownloadInvoice(String(data.value.i
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="w-1/12 px-6 py-6 shrink-0">
|
<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
|
<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"
|
@click="!downloadIsLoading ? downloadFn() : undefined"
|
||||||
:disabled="downloadIsLoading"
|
:disabled="downloadIsLoading"
|
||||||
class="size-9 lg:size-10 flex-center border border-slate-200 rounded-md"
|
class="size-9 lg:size-10 flex-center border border-slate-200 rounded-md"
|
||||||
|
aria-label="دانلود فاکتور"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
v-if="downloadIsLoading"
|
v-if="downloadIsLoading"
|
||||||
@@ -71,6 +91,7 @@ const { downloadFn, downloadIsLoading } = useDownloadInvoice(String(data.value.i
|
|||||||
size="20"
|
size="20"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -23,7 +23,10 @@
|
|||||||
<Skeleton class="w-full !h-10 !rounded-sm" />
|
<Skeleton class="w-full !h-10 !rounded-sm" />
|
||||||
</td>
|
</td>
|
||||||
<td class="w-1/12 px-6 py-6 shrink-0">
|
<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" />
|
||||||
|
<Skeleton class="!size-10 !rounded-sm" />
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const { is_user, files, date } = toRefs(props);
|
|||||||
|
|
||||||
// state
|
// state
|
||||||
|
|
||||||
const timeAgo = usePersianTimeAgo(new Date(date.value));
|
const timeAgo = usePersianTimeAgo(date.value);
|
||||||
|
|
||||||
// queries
|
// queries
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ const { data } = toRefs(props);
|
|||||||
|
|
||||||
// computed
|
// computed
|
||||||
|
|
||||||
const createdTimeAgo = usePersianTimeAgo(new Date(data.value.created_at));
|
const createdTimeAgo = usePersianTimeAgo(data.value.created_at);
|
||||||
const updatedTimeAgo = usePersianTimeAgo(new Date(data.value.updated_at));
|
const updatedTimeAgo = usePersianTimeAgo(data.value.updated_at);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -35,9 +35,6 @@ const useGetChat = (productId: string | number, enabled: Ref<boolean>) => {
|
|||||||
offset,
|
offset,
|
||||||
limit,
|
limit,
|
||||||
},
|
},
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoyMTY3ODE2OTAwLCJpYXQiOjE3MzU4MTY5MDAsImp0aSI6ImQwN2E2Y2Y2NzgwZjRlNTE5NWIzOGQxMTAzYzU4NDQ3IiwidXNlcl9pZCI6NX0.slwd7ZSV7nUXEuDTYwwHUOo9ekCefwEEL4kVv2vSTFo`,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export type GetHomeDataResponse = {
|
|||||||
description2: string;
|
description2: string;
|
||||||
link1: string;
|
link1: string;
|
||||||
link2: string;
|
link2: string;
|
||||||
|
video1: string;
|
||||||
|
video2: string;
|
||||||
};
|
};
|
||||||
show_case_slider: {
|
show_case_slider: {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const useGetAllOrders = () => {
|
|||||||
const handleGetAllOrders = async () => {
|
const handleGetAllOrders = async () => {
|
||||||
const { data } = await axios.get<GetAllOrdersResponse>(API_ENDPOINTS.orders.get_all, {
|
const { data } = await axios.get<GetAllOrdersResponse>(API_ENDPOINTS.orders.get_all, {
|
||||||
params: {
|
params: {
|
||||||
sort: sort.value ?? "created_at",
|
sort: sort.value ?? "-created_at",
|
||||||
status: status.value,
|
status: status.value,
|
||||||
offset: Number(page.value) * 10 - 10,
|
offset: Number(page.value) * 10 - 10,
|
||||||
limit: 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
|
// types
|
||||||
|
|
||||||
export type CreateCommentRequest = {
|
export type CreateCommentRequest = {
|
||||||
|
title : string,
|
||||||
content: string;
|
content: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,35 @@
|
|||||||
// imports
|
// imports
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/vue-query";
|
import { useQuery } from "@tanstack/vue-query";
|
||||||
|
import { useAppParams } from "~/composables/global/useAppParams";
|
||||||
import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
|
import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
|
||||||
|
|
||||||
// types
|
// types
|
||||||
|
|
||||||
export type GetCommentsResponse = ApiPaginated<UserComment>;
|
export type GetCommentsResponse = ApiPaginated<UserComment>;
|
||||||
|
|
||||||
const useGetComments = (id: string | number | undefined, page: Ref<number>) => {
|
const useGetComments = (id: string | number | undefined) => {
|
||||||
|
|
||||||
// state
|
// state
|
||||||
|
|
||||||
const { $axios: axios } = useNuxtApp();
|
const { $axios: axios } = useNuxtApp();
|
||||||
|
|
||||||
|
const { page } = useAppParams();
|
||||||
|
|
||||||
// methods
|
// methods
|
||||||
|
|
||||||
const handleGetComments = async () => {
|
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 data;
|
||||||
};
|
};
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: [QUERY_KEYS.comments, id, page],
|
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 handleGetAllTickets = async () => {
|
||||||
const { data } = await axios.get<GetAllTicketsResponse>(API_ENDPOINTS.tickets.get_all, {
|
const { data } = await axios.get<GetAllTicketsResponse>(API_ENDPOINTS.tickets.get_all, {
|
||||||
params: {
|
params: {
|
||||||
sort: sort.value ?? "created_at",
|
sort: sort.value ?? "-created_at",
|
||||||
filter: status.value,
|
filter: status.value,
|
||||||
offset: Number(page.value) * 7 - 7,
|
offset: Number(page.value) * 7 - 7,
|
||||||
limit: 7,
|
limit: 7,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { PRODUCT_RANGE } from "~/constants";
|
|||||||
export const useAppParams = () => {
|
export const useAppParams = () => {
|
||||||
// state
|
// state
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
const { y } = useWindowScroll({ behavior: "smooth" });
|
const { y } = useWindowScroll({ behavior: "smooth" });
|
||||||
|
|
||||||
const slug = useRouteParams<string | undefined>("slug");
|
const slug = useRouteParams<string | undefined>("slug");
|
||||||
@@ -58,7 +59,9 @@ export const useAppParams = () => {
|
|||||||
watch(
|
watch(
|
||||||
() => page.value,
|
() => page.value,
|
||||||
() => {
|
() => {
|
||||||
|
if (route.name !== "product-id") {
|
||||||
y.value = 0;
|
y.value = 0;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
// composables/usePersianDate.ts
|
// composables/usePersianDate.ts
|
||||||
import { format, toDate } from "date-fns-jalali";
|
import { format } from "date-fns-jalali";
|
||||||
import { faIR } from "date-fns-jalali/locale";
|
import { faIR } from "date-fns-jalali/locale";
|
||||||
|
import { Jalali } from "jalali-ts";
|
||||||
|
|
||||||
export default function usePersianDate() {
|
export default function usePersianDate() {
|
||||||
const formatToPersian = (isoDate: string): string => {
|
const formatToPersian = (isoDate: string): string => {
|
||||||
try {
|
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 });
|
const persianDate = format(date, "yyyy/MM/dd", { locale: faIR });
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,44 @@
|
|||||||
// composables/usePersianTimeAgo.ts
|
// composables/usePersianTimeAgo.ts
|
||||||
import { formatDistance, toDate } from "date-fns-jalali";
|
import { formatDistance } from "date-fns-jalali";
|
||||||
import { faIR } from "date-fns-jalali/locale";
|
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 timeAgo = ref("");
|
||||||
|
|
||||||
const updateTimeAgo = () => {
|
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,
|
addSuffix: true,
|
||||||
locale: faIR,
|
locale: faIR,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const API_ENDPOINTS = {
|
|||||||
comments: "/products/comments",
|
comments: "/products/comments",
|
||||||
create_comment: "/products/comments",
|
create_comment: "/products/comments",
|
||||||
get: "/products",
|
get: "/products",
|
||||||
|
rate: "/products",
|
||||||
save: "/accounts/favorites/toggle",
|
save: "/accounts/favorites/toggle",
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
@@ -55,6 +56,7 @@ export const API_ENDPOINTS = {
|
|||||||
},
|
},
|
||||||
orders: {
|
orders: {
|
||||||
get_all: "/order/all",
|
get_all: "/order/all",
|
||||||
|
get_one: "/order",
|
||||||
cart: {
|
cart: {
|
||||||
download_invoice: "/order/invoice",
|
download_invoice: "/order/invoice",
|
||||||
get_all: "/order/cart",
|
get_all: "/order/cart",
|
||||||
@@ -93,6 +95,7 @@ export const QUERY_KEYS = {
|
|||||||
tickets: "tickets",
|
tickets: "tickets",
|
||||||
ticket: "ticket",
|
ticket: "ticket",
|
||||||
orders: "orders",
|
orders: "orders",
|
||||||
|
order: "order",
|
||||||
cart: "cart",
|
cart: "cart",
|
||||||
transaction: "transaction",
|
transaction: "transaction",
|
||||||
notifications: "notifications",
|
notifications: "notifications",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default defineNuxtConfig({
|
|||||||
|
|
||||||
app: {
|
app: {
|
||||||
pageTransition: {
|
pageTransition: {
|
||||||
name: "fade",
|
name: "layout-fade",
|
||||||
mode: "out-in",
|
mode: "out-in",
|
||||||
},
|
},
|
||||||
head: {
|
head: {
|
||||||
|
|||||||
@@ -42,7 +42,8 @@
|
|||||||
"ogl": "^1.0.11",
|
"ogl": "^1.0.11",
|
||||||
"reka-ui": "^1.0.0-alpha.11",
|
"reka-ui": "^1.0.0-alpha.11",
|
||||||
"sanitize-html": "^2.17.3",
|
"sanitize-html": "^2.17.3",
|
||||||
"swiper": "^11.2.10",
|
"sharp": "^0.34.4",
|
||||||
|
"swiper": "^12.1.4",
|
||||||
"universal-cookie": "^7.2.2",
|
"universal-cookie": "^7.2.2",
|
||||||
"vue": "^3.5.33",
|
"vue": "^3.5.33",
|
||||||
"vue-image-zoomer": "^2.4.4",
|
"vue-image-zoomer": "^2.4.4",
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ const router = useRouter();
|
|||||||
const paymentGateways = ref<PaymentGateway[]>([
|
const paymentGateways = ref<PaymentGateway[]>([
|
||||||
{
|
{
|
||||||
id: 5,
|
id: 5,
|
||||||
picture: "/img/gateways/zibal.png",
|
picture: "/img/gateways/zarinpal.png",
|
||||||
title: "زیبال",
|
title: "زرین پال",
|
||||||
type: "ZIBAL",
|
type: "ZARINPAL",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ definePageMeta({
|
|||||||
middleware: "check-is-logged-in",
|
middleware: "check-is-logged-in",
|
||||||
|
|
||||||
prevPage: { name: "cart", label: "سبد خرید" },
|
prevPage: { name: "cart", label: "سبد خرید" },
|
||||||
nextPage: { name: "cart-checkout", label: "تسویه حساب", query: "ZIBAL" },
|
nextPage: { name: "cart-checkout", label: "تسویه حساب", query: "ZARINPAL" },
|
||||||
});
|
});
|
||||||
|
|
||||||
// types
|
// types
|
||||||
|
|||||||
@@ -70,13 +70,12 @@ const contactWays = ref<{ title: string; ways: { type: "text" | "link"; title: s
|
|||||||
ways: [
|
ways: [
|
||||||
{
|
{
|
||||||
type: "link",
|
type: "link",
|
||||||
title: "09026663488",
|
title: "021-93111026",
|
||||||
path: "tell:09026663488",
|
path: "tell:02193111026",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "link",
|
type: "text",
|
||||||
title: "09022202311",
|
title: "ارتباط با پشتیبانی: داخلی ۱",
|
||||||
path: "tell:09022202311",
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 ChatButton from "~/components/product/ChatBox/ChatButton.vue";
|
||||||
import useGetProduct from "~/composables/api/product/useGetProduct";
|
import useGetProduct from "~/composables/api/product/useGetProduct";
|
||||||
import ProductsSlider from "~/components/global/product-detail/ProductsSlider.vue";
|
import ProductsSlider from "~/components/global/product-detail/ProductsSlider.vue";
|
||||||
|
import { useAppParams } from "~/composables/global/useAppParams";
|
||||||
|
|
||||||
// state
|
// state
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const id = route.params.id as string | undefined;
|
const id = route.params.id as string | undefined;
|
||||||
|
|
||||||
|
const { page } = useAppParams();
|
||||||
|
|
||||||
const { suspense: suspenseProduct, data: product } = useGetProduct(id);
|
const { suspense: suspenseProduct, data: product } = useGetProduct(id);
|
||||||
|
|
||||||
useSeoMeta({
|
useSeoMeta({
|
||||||
@@ -49,6 +51,12 @@ if (productResponse.isError) {
|
|||||||
statusMessage: `error : product ${id} prefetch error`,
|
statusMessage: `error : product ${id} prefetch error`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lifecycle
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
page.value = 1;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -56,12 +64,15 @@ if (productResponse.isError) {
|
|||||||
<ProductHero />
|
<ProductHero />
|
||||||
<ProductVideo v-model:showChatButton="showChatButton" />
|
<ProductVideo v-model:showChatButton="showChatButton" />
|
||||||
<ProductDetails />
|
<ProductDetails />
|
||||||
|
<div class="py-20">
|
||||||
<ProductsSlider
|
<ProductsSlider
|
||||||
title="محصولات مشابه"
|
title="محصولات مشابه"
|
||||||
:products="product!.related_products"
|
:products="product!.related_products"
|
||||||
iconImage="/img/simulare-products-section.gif"
|
iconImage="/img/simulare-products-section.gif"
|
||||||
/>
|
/>
|
||||||
<ProductComments />
|
</div>
|
||||||
<ChatButton :showChatButton="showChatButton" />
|
<ProductComments :product="product!" />
|
||||||
|
|
||||||
|
<!-- <ChatButton :showChatButton="showChatButton" /> -->
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ useSeoMeta({
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="w-full container flex flex-col">
|
<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="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-[1rem] lg:gap-[1.5rem] text-black w-full">
|
<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">
|
<div class="flex gap-2 items-center">
|
||||||
<NuxtImg
|
<NuxtImg
|
||||||
src="/img/poducts-list-section.gif"
|
src="/img/poducts-list-section.gif"
|
||||||
@@ -61,12 +61,12 @@ useSeoMeta({
|
|||||||
placeholder="جست و جو محصول ..."
|
placeholder="جست و جو محصول ..."
|
||||||
v-model="search"
|
v-model="search"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
class="!rounded-xl w-full lg:w-8/12"
|
class="rounded-xl! w-full lg:w-8/12"
|
||||||
>
|
>
|
||||||
<template #endItem>
|
<template #endItem>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<Icon
|
<Icon
|
||||||
class="translate-y-[-1px] text-[20px] lg:text-[24px]"
|
class="-translate-y-px text-[20px] lg:text-[24px]"
|
||||||
name="ci:search"
|
name="ci:search"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,7 +79,7 @@ useSeoMeta({
|
|||||||
</template>
|
</template>
|
||||||
</FilterButton>
|
</FilterButton>
|
||||||
<template #fallback>
|
<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>
|
</template>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,10 +98,10 @@ useSeoMeta({
|
|||||||
:key="i"
|
:key="i"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:class="{
|
:class="{
|
||||||
'!h-[11.9rem] lg:!h-[17.25rem] !rounded-2xl': i == 1,
|
'h-[11.9rem]! lg:h-69! rounded-2xl!': i == 1,
|
||||||
'!h-[1.4rem] lg:!h-[1.5rem] !rounded-sm': [2, 3].includes(i),
|
'h-[1.4rem]! lg:h-6! rounded-sm!': [2, 3].includes(i),
|
||||||
'!w-1/2 lg:!w-full': i == 2,
|
'w-1/2! lg:w-full!': i == 2,
|
||||||
'lg:!w-1/2': i == 3,
|
'lg:w-1/2!': i == 3,
|
||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,7 +112,7 @@ useSeoMeta({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="!products?.length"
|
v-if="!products?.length"
|
||||||
class="flex flex-grow w-full"
|
class="flex grow w-full"
|
||||||
>
|
>
|
||||||
<Placeholder
|
<Placeholder
|
||||||
title="محصولی یافت نشد :("
|
title="محصولی یافت نشد :("
|
||||||
@@ -122,7 +122,7 @@ useSeoMeta({
|
|||||||
<ProductsGrid
|
<ProductsGrid
|
||||||
:with-header="false"
|
:with-header="false"
|
||||||
:products="products!"
|
:products="products!"
|
||||||
class="!p-0"
|
class="p-0!"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="data && paginationData && data.count > 15"
|
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([
|
const sortFilters = ref([
|
||||||
{
|
{
|
||||||
title: "جدید ترین",
|
title: "جدید ترین",
|
||||||
value: "created_at",
|
value: "-created_at",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "قدیمی ترین",
|
title: "قدیمی ترین",
|
||||||
value: "-created_at",
|
value: "created_at",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -76,8 +76,8 @@ const paginationData = computed(() => {
|
|||||||
// methods
|
// methods
|
||||||
|
|
||||||
const clearFilters = () => {
|
const clearFilters = () => {
|
||||||
sort.value = "";
|
sort.value = undefined;
|
||||||
status.value = "";
|
status.value = undefined;
|
||||||
};
|
};
|
||||||
</script>
|
</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>
|
||||||
<DataField
|
<DataField
|
||||||
id="orders"
|
id="orders"
|
||||||
:required="true"
|
:required="false"
|
||||||
label="خرید یا سفارش"
|
label="خرید یا سفارش"
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
@@ -245,6 +245,16 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
<template #content>
|
<template #content>
|
||||||
<SelectGroup>
|
<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
|
<SelectItem
|
||||||
v-for="(order, index) in orders?.results"
|
v-for="(order, index) in orders?.results"
|
||||||
:key="index"
|
: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;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
rating: number;
|
rating: number;
|
||||||
|
user_rating: number | null;
|
||||||
slug: string;
|
slug: string;
|
||||||
meta_description: string | null;
|
meta_description: string | null;
|
||||||
meta_keywords: string | null;
|
meta_keywords: string | null;
|
||||||
@@ -155,7 +156,9 @@ declare global {
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
show: boolean;
|
show: boolean;
|
||||||
product: number;
|
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 = {
|
type Category = {
|
||||||
@@ -220,6 +223,27 @@ declare global {
|
|||||||
special_discount_total?: string;
|
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 = {
|
type DiscountCode = {
|
||||||
code: string;
|
code: string;
|
||||||
percent: number;
|
percent: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user