Compare commits

..

64 Commits

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

+25 -1
View File
@@ -101,6 +101,7 @@ declare global {
name: string;
description: string;
rating: number;
user_rating: number | null;
slug: string;
meta_description: string | null;
meta_keywords: string | null;
@@ -155,7 +156,9 @@ declare global {
timestamp: string;
show: boolean;
product: number;
user: number;
user: { id: number; first_name: string; last_name: string; profile_photo: string | null; phone: string };
title: string;
user_rating: number | null;
};
type Category = {
@@ -220,6 +223,27 @@ declare global {
special_discount_total?: string;
};
type OrderDetailItem = {
id: number;
product: CartItem["product"];
quantity: number;
price: string;
final_price: string;
discount: number;
discount_amount: string;
special_discount_amount: string | null;
discount_percent?: number;
};
type OrderDetail = Order & {
items: OrderDetailItem[];
address: Address | null;
tax: number | string | null;
cart_total: number | string | null;
discount_code: DiscountCode | null;
discount_amount: number | string | null;
};
type DiscountCode = {
code: string;
percent: number;