Compare commits
21 Commits
8960744a81
...
c1d08496c9
| Author | SHA1 | Date | |
|---|---|---|---|
| c1d08496c9 | |||
| 0567593edd | |||
| 6f2037309c | |||
| e6965fe3b8 | |||
| a89376e4c4 | |||
| c07d4b802b | |||
| cc8ced184d | |||
| 335a0c2f7e | |||
| cc98dc4ccf | |||
| 2b1c2b72c1 | |||
| e5eaf80199 | |||
| 74a61844a0 | |||
| ee7b7eebad | |||
| 642b41ffaf | |||
| f0b03e27b3 | |||
| ccf18fb768 | |||
| b09995920c | |||
| 7ccb2f445e | |||
| 048f5435ff | |||
| 886a3ee541 | |||
| ee90708751 |
@@ -414,7 +414,7 @@ class ProductModelAdmin(ProductAdminPermission, ModelAdmin, ImportExportModelAdm
|
|||||||
# compressed_fields = True
|
# compressed_fields = True
|
||||||
warn_unsaved_form = True
|
warn_unsaved_form = True
|
||||||
# list_per_page = 2
|
# list_per_page = 2
|
||||||
actions_list = ['redirect_to_learn', 'update_products_price']
|
actions_list = ['redirect_to_learn', 'update_products_price', 'resync_all_torob']
|
||||||
list_display = ['display_image', 'shop__shop_name', 'view', 'rating', 'category', 'created_at' ,'show_in_website', ]
|
list_display = ['display_image', 'shop__shop_name', 'view', 'rating', 'category', 'created_at' ,'show_in_website', ]
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('فیلد های اصلی', {'fields': ('name', 'description', 'category', 'image', 'related_products','show_in_trends', 'show_in_most_viewed', 'show_in_lot_of_discount', 'show_in_top_seller', 'shop', 'show_in_bot', 'bot_banner'), "classes": ["tab"],}),
|
('فیلد های اصلی', {'fields': ('name', 'description', 'category', 'image', 'related_products','show_in_trends', 'show_in_most_viewed', 'show_in_lot_of_discount', 'show_in_top_seller', 'shop', 'show_in_bot', 'bot_banner'), "classes": ["tab"],}),
|
||||||
@@ -497,6 +497,57 @@ class ProductModelAdmin(ProductAdminPermission, ModelAdmin, ImportExportModelAdm
|
|||||||
messages.success(request, f"قیمت {ProductVariant.objects.all().count()} تنوع محصول اپدیت شد")
|
messages.success(request, f"قیمت {ProductVariant.objects.all().count()} تنوع محصول اپدیت شد")
|
||||||
return redirect("admin:product_productmodel_changelist")
|
return redirect("admin:product_productmodel_changelist")
|
||||||
|
|
||||||
|
@action(description="ارسال مجدد همه محصولات به ترب")
|
||||||
|
def resync_all_torob(self, request):
|
||||||
|
from django.conf import settings
|
||||||
|
from product.tasks import send_torob_product_webhook
|
||||||
|
|
||||||
|
if not getattr(settings, "TOROB_PRODUCT_WEBHOOK_TOKEN", None):
|
||||||
|
messages.error(request, "توکن وبهوک ترب در تنظیمات وجود ندارد")
|
||||||
|
return redirect("admin:product_productmodel_changelist")
|
||||||
|
|
||||||
|
product_ids = list(
|
||||||
|
ProductModel.objects.exclude(slug__isnull=True).exclude(slug="").values_list("id", flat=True)
|
||||||
|
)
|
||||||
|
if not product_ids:
|
||||||
|
messages.warning(request, "محصولی برای ارسال یافت نشد")
|
||||||
|
return redirect("admin:product_productmodel_changelist")
|
||||||
|
|
||||||
|
chunk_size = 50
|
||||||
|
queued = 0
|
||||||
|
for start in range(0, len(product_ids), chunk_size):
|
||||||
|
send_torob_product_webhook.delay(product_ids[start:start + chunk_size])
|
||||||
|
queued += 1
|
||||||
|
|
||||||
|
messages.success(
|
||||||
|
request,
|
||||||
|
f"{len(product_ids)} محصول در {queued} بسته برای ارسال به ترب در صف قرار گرفت",
|
||||||
|
)
|
||||||
|
return redirect("admin:product_productmodel_changelist")
|
||||||
|
|
||||||
|
def resync_selected_torob(self, request, queryset):
|
||||||
|
from django.conf import settings
|
||||||
|
from product.tasks import send_torob_product_webhook
|
||||||
|
|
||||||
|
if not getattr(settings, "TOROB_PRODUCT_WEBHOOK_TOKEN", None):
|
||||||
|
messages.error(request, "توکن وبهوک ترب در تنظیمات وجود ندارد")
|
||||||
|
return
|
||||||
|
|
||||||
|
product_ids = list(
|
||||||
|
queryset.exclude(slug__isnull=True).exclude(slug="").values_list("id", flat=True)
|
||||||
|
)
|
||||||
|
if not product_ids:
|
||||||
|
messages.warning(request, "محصول معتبری انتخاب نشد")
|
||||||
|
return
|
||||||
|
|
||||||
|
chunk_size = 50
|
||||||
|
for start in range(0, len(product_ids), chunk_size):
|
||||||
|
send_torob_product_webhook.delay(product_ids[start:start + chunk_size])
|
||||||
|
|
||||||
|
messages.success(request, f"{len(product_ids)} محصول برای ارسال به ترب در صف قرار گرفت")
|
||||||
|
|
||||||
|
resync_selected_torob.short_description = "ارسال محصولات انتخاب شده به ترب"
|
||||||
|
|
||||||
def bulk_update_subcategory_action(self, request, queryset):
|
def bulk_update_subcategory_action(self, request, queryset):
|
||||||
"""اکشن برای تغییر دستهبندی چند محصول همزمان"""
|
"""اکشن برای تغییر دستهبندی چند محصول همزمان"""
|
||||||
|
|
||||||
@@ -535,7 +586,7 @@ class ProductModelAdmin(ProductAdminPermission, ModelAdmin, ImportExportModelAdm
|
|||||||
)
|
)
|
||||||
|
|
||||||
bulk_update_subcategory_action.short_description = "تغییر دستهبندی محصولات انتخاب شده"
|
bulk_update_subcategory_action.short_description = "تغییر دستهبندی محصولات انتخاب شده"
|
||||||
actions = ['bulk_update_subcategory_action']
|
actions = ['bulk_update_subcategory_action', 'resync_selected_torob']
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("product", "0074_productmodel_updated_at_productvariant_updated_at"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="productvariant",
|
||||||
|
name="guarantee",
|
||||||
|
field=models.CharField(
|
||||||
|
default="گرانتی اصالت و سلامت کالا",
|
||||||
|
help_text="این مقدار به ترب ارسال میشود تا محصول بهعنوان نو طبقهبندی شود",
|
||||||
|
max_length=200,
|
||||||
|
verbose_name="گارانتی",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""
|
||||||
|
Add an IMMUTABLE ``normalize_persian(text)`` SQL function and GIN trigram
|
||||||
|
indexes that match it. Lets the search view filter and rank without doing a
|
||||||
|
per-row ``translate()`` on a sequential scan — drops query time from seconds
|
||||||
|
to tens of milliseconds.
|
||||||
|
|
||||||
|
The FROM/TO strings here MUST stay aligned with ``_SQL_NORM_FROM`` /
|
||||||
|
``_SQL_NORM_TO`` in ``product/views.py``. If you change one, change the other
|
||||||
|
and add a follow-up migration that recreates the function + indexes (Postgres
|
||||||
|
matches expression indexes by exact SQL form, so a stale function would
|
||||||
|
silently bypass the indexes).
|
||||||
|
"""
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
# Mirror of product.views._SQL_NORM_FROM / _SQL_NORM_TO.
|
||||||
|
_SQL_NORM_FROM = (
|
||||||
|
'يك' # Arabic ya/kaf -> Persian
|
||||||
|
'ﻱﻲﻳﻴ' # Arabic ya presentation forms
|
||||||
|
'ﻙﻚﻛﻜ' # Arabic kaf presentation forms
|
||||||
|
'آأإٱ' # alef variants
|
||||||
|
'ؤ' # waw with hamza
|
||||||
|
'ئ' # ya with hamza
|
||||||
|
'ةۀ' # ta marbuta / he with hamza
|
||||||
|
'ﻩﻪﻫﻬ' # he presentation forms
|
||||||
|
'' # ZWNJ, ZWJ -> space
|
||||||
|
'۰۱۲۳۴۵۶۷۸۹' # Persian digits
|
||||||
|
'٠١٢٣٤٥٦٧٨٩' # Arabic-Indic digits
|
||||||
|
# Deletions (no matching char in TO):
|
||||||
|
'ـ' # tatweel
|
||||||
|
'' # LRM, RLM
|
||||||
|
'ًٌٍَُِّْ' # tashkeel
|
||||||
|
)
|
||||||
|
_SQL_NORM_TO = (
|
||||||
|
'یک'
|
||||||
|
'یییی'
|
||||||
|
'کککک'
|
||||||
|
'اااا'
|
||||||
|
'و'
|
||||||
|
'ی'
|
||||||
|
'هه'
|
||||||
|
'هههه'
|
||||||
|
' '
|
||||||
|
'0123456789'
|
||||||
|
'0123456789'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pg_str(s):
|
||||||
|
"""Quote a Python string as a PostgreSQL string literal."""
|
||||||
|
return "'" + s.replace("'", "''") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
CREATE_FUNCTION_SQL = f"""
|
||||||
|
CREATE OR REPLACE FUNCTION normalize_persian(t text) RETURNS text AS $$
|
||||||
|
SELECT lower(translate(t, {_pg_str(_SQL_NORM_FROM)}, {_pg_str(_SQL_NORM_TO)}));
|
||||||
|
$$ LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE;
|
||||||
|
"""
|
||||||
|
|
||||||
|
DROP_FUNCTION_SQL = "DROP FUNCTION IF EXISTS normalize_persian(text);"
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("product", "0075_productvariant_guarantee"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunSQL(
|
||||||
|
sql=CREATE_FUNCTION_SQL,
|
||||||
|
reverse_sql=DROP_FUNCTION_SQL,
|
||||||
|
),
|
||||||
|
# GIN trigram indexes on the normalized expression. PostgreSQL matches
|
||||||
|
# queries that use exactly ``normalize_persian(<col>)`` against these
|
||||||
|
# indexes, so the views.py wrapper must call the SQL function (not
|
||||||
|
# inline translate/lower) for the index to be used.
|
||||||
|
migrations.RunSQL(
|
||||||
|
sql=(
|
||||||
|
"CREATE INDEX IF NOT EXISTS product_norm_name_trgm_idx "
|
||||||
|
"ON product_productmodel "
|
||||||
|
"USING gin (normalize_persian(name) gin_trgm_ops);"
|
||||||
|
),
|
||||||
|
reverse_sql="DROP INDEX IF EXISTS product_norm_name_trgm_idx;",
|
||||||
|
),
|
||||||
|
migrations.RunSQL(
|
||||||
|
sql=(
|
||||||
|
"CREATE INDEX IF NOT EXISTS product_norm_keywords_trgm_idx "
|
||||||
|
"ON product_productmodel "
|
||||||
|
"USING gin (normalize_persian(meta_keywords) gin_trgm_ops);"
|
||||||
|
),
|
||||||
|
reverse_sql="DROP INDEX IF EXISTS product_norm_keywords_trgm_idx;",
|
||||||
|
),
|
||||||
|
migrations.RunSQL(
|
||||||
|
sql=(
|
||||||
|
"CREATE INDEX IF NOT EXISTS subcategory_norm_name_trgm_idx "
|
||||||
|
"ON product_subcategorymodel "
|
||||||
|
"USING gin (normalize_persian(name) gin_trgm_ops);"
|
||||||
|
),
|
||||||
|
reverse_sql="DROP INDEX IF EXISTS subcategory_norm_name_trgm_idx;",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2 on 2026-06-02 05:16
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('product', '0076_normalize_persian_search'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='productvariant',
|
||||||
|
name='guarantee',
|
||||||
|
field=models.CharField(default='گارانتی اصالت و سلامت کالا', help_text='این مقدار به ترب ارسال می\u200cشود تا محصول به\u200cعنوان نو طبقه\u200cبندی شود', max_length=200, verbose_name='گارانتی'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -147,7 +147,7 @@ class DollorModel(models.Model):
|
|||||||
def get_usd_price(self):
|
def get_usd_price(self):
|
||||||
try:
|
try:
|
||||||
api_usd = "https://api.nobitex.ir/v2/orderbook/USDTIRT"
|
api_usd = "https://api.nobitex.ir/v2/orderbook/USDTIRT"
|
||||||
response = requests.get(api_usd)
|
response = requests.get(api_usd, timeout=5)
|
||||||
data = response.json()
|
data = response.json()
|
||||||
price = int(data["lastTradePrice"])
|
price = int(data["lastTradePrice"])
|
||||||
price_in_usd = price / 10.0
|
price_in_usd = price / 10.0
|
||||||
@@ -403,6 +403,10 @@ class ProductVariant(DirtyFieldsMixin, models.Model):
|
|||||||
discount = models.SmallIntegerField(default=0, verbose_name='درصد تخفیف', help_text='این درصد از قیمت نهایی محصول کسر میگردد')
|
discount = models.SmallIntegerField(default=0, verbose_name='درصد تخفیف', help_text='این درصد از قیمت نهایی محصول کسر میگردد')
|
||||||
color = models.CharField(
|
color = models.CharField(
|
||||||
verbose_name='رنگ', max_length=7, blank=True, null=True)
|
verbose_name='رنگ', max_length=7, blank=True, null=True)
|
||||||
|
guarantee = models.CharField(
|
||||||
|
max_length=200, default='گارانتی اصالت و سلامت کالا',
|
||||||
|
verbose_name='گارانتی',
|
||||||
|
help_text='این مقدار به ترب ارسال میشود تا محصول بهعنوان نو طبقهبندی شود')
|
||||||
images = models.ManyToManyField(ProductImageModel, verbose_name='عکس ها')
|
images = models.ManyToManyField(ProductImageModel, verbose_name='عکس ها')
|
||||||
video = models.FileField(upload_to='product_videos/',
|
video = models.FileField(upload_to='product_videos/',
|
||||||
blank=True, null=True, verbose_name='ویدیو')
|
blank=True, null=True, verbose_name='ویدیو')
|
||||||
|
|||||||
+13
-10
@@ -17,13 +17,16 @@ TOROB_WEBHOOK_MIN_INTERVAL_SECONDS = 3.1
|
|||||||
TOROB_WEBHOOK_MAX_RETRIES = 3
|
TOROB_WEBHOOK_MAX_RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
def _shop_product_url(product: ProductModel) -> str:
|
def _shop_product_url(product: ProductModel, variant: ProductVariant | None = None) -> str:
|
||||||
domain = getattr(settings, "DOMAIN", None) or getattr(settings, "API_DOMAIN", None) or ""
|
domain = getattr(settings, "DOMAIN", None) or getattr(settings, "API_DOMAIN", None) or ""
|
||||||
if domain.startswith("http://") or domain.startswith("https://"):
|
if domain.startswith("http://") or domain.startswith("https://"):
|
||||||
base = domain.rstrip("/")
|
base = domain.rstrip("/")
|
||||||
else:
|
else:
|
||||||
base = f"https://{domain}".rstrip("/") if domain else ""
|
base = f"https://{domain}".rstrip("/") if domain else ""
|
||||||
return f"{base}/product/{product.slug}/"
|
url = f"{base}/product/{product.slug}/"
|
||||||
|
if variant is not None:
|
||||||
|
url = f"{url}?variant={variant.pk}"
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
def _variant_page_unique(product: ProductModel, variant: ProductVariant) -> str:
|
def _variant_page_unique(product: ProductModel, variant: ProductVariant) -> str:
|
||||||
@@ -124,18 +127,18 @@ def send_torob_product_webhook(product_ids):
|
|||||||
if not product.slug:
|
if not product.slug:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
page_url = _shop_product_url(product)
|
|
||||||
parsed = urlparse(page_url)
|
|
||||||
if not (parsed.scheme in {"http", "https"} and parsed.netloc):
|
|
||||||
logger.warning("Skipping product %s due to invalid page_url: %s", product.pk, page_url)
|
|
||||||
continue
|
|
||||||
hosts.add(parsed.netloc.lower())
|
|
||||||
|
|
||||||
variants = list(product.variants.all())
|
variants = list(product.variants.all())
|
||||||
if not variants:
|
if not variants:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for variant in variants:
|
for variant in variants:
|
||||||
|
variant_page_url = _shop_product_url(product, variant)
|
||||||
|
parsed = urlparse(variant_page_url)
|
||||||
|
if not (parsed.scheme in {"http", "https"} and parsed.netloc):
|
||||||
|
logger.warning("Skipping variant %s due to invalid page_url: %s", variant.pk, variant_page_url)
|
||||||
|
continue
|
||||||
|
hosts.add(parsed.netloc.lower())
|
||||||
|
|
||||||
# Validate variant has images before sending to Torob
|
# Validate variant has images before sending to Torob
|
||||||
# Per spec: image_links is required, so skip variants without images
|
# Per spec: image_links is required, so skip variants without images
|
||||||
images = list(variant.images.all())
|
images = list(variant.images.all())
|
||||||
@@ -148,7 +151,7 @@ def send_torob_product_webhook(product_ids):
|
|||||||
|
|
||||||
items.append(
|
items.append(
|
||||||
{
|
{
|
||||||
"page_url": page_url,
|
"page_url": variant_page_url,
|
||||||
"page_unique": _variant_page_unique(product, variant),
|
"page_unique": _variant_page_unique(product, variant),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -80,6 +80,18 @@ def _extract_slug_from_url(value: str) -> str | None:
|
|||||||
return path.split("/")[-1]
|
return path.split("/")[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_variant_id_from_url(value: str) -> str | None:
|
||||||
|
query = urlparse(value).query
|
||||||
|
if not query:
|
||||||
|
return None
|
||||||
|
params = parse_qs(query)
|
||||||
|
variant_values = params.get("variant") or []
|
||||||
|
if not variant_values:
|
||||||
|
return None
|
||||||
|
candidate = variant_values[0].strip()
|
||||||
|
return candidate or None
|
||||||
|
|
||||||
|
|
||||||
def _variant_page_unique(product: ProductModel, variant: ProductVariant) -> str:
|
def _variant_page_unique(product: ProductModel, variant: ProductVariant) -> str:
|
||||||
return f"{product.pk}_{variant.pk}"
|
return f"{product.pk}_{variant.pk}"
|
||||||
|
|
||||||
@@ -109,13 +121,15 @@ def _absolute_url(request, value: str) -> str:
|
|||||||
return request.build_absolute_uri(value)
|
return request.build_absolute_uri(value)
|
||||||
|
|
||||||
|
|
||||||
def _shop_product_url(request, product: ProductModel) -> str:
|
def _shop_product_url(request, product: ProductModel, variant: ProductVariant | None = None) -> str:
|
||||||
domain = getattr(settings, "DOMAIN", None) or getattr(settings, "API_DOMAIN", None) or request.get_host()
|
domain = getattr(settings, "DOMAIN", None) or getattr(settings, "API_DOMAIN", None) or request.get_host()
|
||||||
if domain.startswith("http://") or domain.startswith("https://"):
|
if domain.startswith("http://") or domain.startswith("https://"):
|
||||||
base = domain.rstrip("/")
|
base = domain.rstrip("/")
|
||||||
else:
|
else:
|
||||||
base = f"https://{domain}".rstrip("/")
|
base = f"https://{domain}".rstrip("/")
|
||||||
url = f"{base}/product/{product.slug}/"
|
url = f"{base}/product/{product.slug}/"
|
||||||
|
if variant is not None:
|
||||||
|
url = f"{url}?variant={variant.pk}"
|
||||||
# Per spec: page_url max 1500 chars
|
# Per spec: page_url max 1500 chars
|
||||||
return url[:1500]
|
return url[:1500]
|
||||||
|
|
||||||
@@ -140,9 +154,6 @@ def _variant_spec(variant: ProductVariant | None) -> dict:
|
|||||||
if variant.color:
|
if variant.color:
|
||||||
spec.setdefault("color", variant.color)
|
spec.setdefault("color", variant.color)
|
||||||
|
|
||||||
if variant.in_stock is not None:
|
|
||||||
spec.setdefault("in_stock", variant.in_stock)
|
|
||||||
|
|
||||||
return spec
|
return spec
|
||||||
|
|
||||||
|
|
||||||
@@ -213,7 +224,7 @@ def _serialize_variant(request, product: ProductModel, variant: ProductVariant)
|
|||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"page_unique": _variant_page_unique(product, variant),
|
"page_unique": _variant_page_unique(product, variant),
|
||||||
"page_url": _shop_product_url(request, product),
|
"page_url": _shop_product_url(request, product, variant),
|
||||||
"product_group_id": str(product.pk),
|
"product_group_id": str(product.pk),
|
||||||
"title": _truncate_text(product.name, 500),
|
"title": _truncate_text(product.name, 500),
|
||||||
"subtitle": _truncate_text(product.meta_description, 500),
|
"subtitle": _truncate_text(product.meta_description, 500),
|
||||||
@@ -222,11 +233,11 @@ def _serialize_variant(request, product: ProductModel, variant: ProductVariant)
|
|||||||
"category_name": _truncate_text(product.category.name if product.category else None, 200),
|
"category_name": _truncate_text(product.category.name if product.category else None, 200),
|
||||||
"image_links": _product_image_links(request, product, variant),
|
"image_links": _product_image_links(request, product, variant),
|
||||||
"spec": _variant_spec(variant),
|
"spec": _variant_spec(variant),
|
||||||
"guarantee": None,
|
"guarantee": _truncate_text(variant.guarantee, 200),
|
||||||
"short_desc": _truncate_text(product.description, 500),
|
"short_desc": _truncate_text(product.description, 500),
|
||||||
"date_added": _variant_date_added(product, variant),
|
"date_added": _variant_date_added(product, variant),
|
||||||
"date_updated": _variant_date_updated(product, variant),
|
"date_updated": _variant_date_updated(product, variant),
|
||||||
"seller_name": product.shop.shop_name if product.shop else None,
|
# "seller_name": product.shop.shop_name if product.shop else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
if old_price is not None and old_price > current_price:
|
if old_price is not None and old_price > current_price:
|
||||||
@@ -336,27 +347,49 @@ class TorobProductSyncView(APIView):
|
|||||||
for product in products
|
for product in products
|
||||||
}
|
}
|
||||||
|
|
||||||
ordered_products = []
|
ordered_lookups: list[tuple[ProductModel, str | None]] = []
|
||||||
for url in requested_urls:
|
for url in requested_urls:
|
||||||
slug = _extract_slug_from_url(url)
|
slug = _extract_slug_from_url(url)
|
||||||
normalized_url = _normalize_url(url)
|
normalized_url = _normalize_url(url)
|
||||||
|
variant_id = _extract_variant_id_from_url(url)
|
||||||
product = product_by_slug.get(slug) if slug else None
|
product = product_by_slug.get(slug) if slug else None
|
||||||
if product is None:
|
if product is None:
|
||||||
product = product_by_url.get(normalized_url)
|
product = product_by_url.get(normalized_url)
|
||||||
if product is not None and product not in ordered_products:
|
if product is None:
|
||||||
ordered_products.append(product)
|
continue
|
||||||
|
ordered_lookups.append((product, variant_id))
|
||||||
|
|
||||||
serialized_products = []
|
serialized_products = []
|
||||||
for product in ordered_products:
|
seen: set[str] = set()
|
||||||
|
for product, variant_id in ordered_lookups:
|
||||||
variants = list(product.variants.all())
|
variants = list(product.variants.all())
|
||||||
if not variants:
|
if not variants:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if variant_id:
|
||||||
|
variant = next((v for v in variants if str(v.pk) == variant_id), None)
|
||||||
|
if not variant:
|
||||||
|
continue
|
||||||
|
image_links = _product_image_links(request, product, variant)
|
||||||
|
if not image_links:
|
||||||
|
continue
|
||||||
|
page_unique = _variant_page_unique(product, variant)
|
||||||
|
if page_unique in seen:
|
||||||
|
continue
|
||||||
|
seen.add(page_unique)
|
||||||
|
serialized_products.append(_serialize_variant(request, product, variant))
|
||||||
|
continue
|
||||||
|
|
||||||
variants.sort(key=_variant_sort_key)
|
variants.sort(key=_variant_sort_key)
|
||||||
for variant in variants:
|
for variant in variants:
|
||||||
image_links = _product_image_links(request, product, variant)
|
image_links = _product_image_links(request, product, variant)
|
||||||
# Skip variants without images as per spec requirement
|
# Skip variants without images as per spec requirement
|
||||||
if not image_links:
|
if not image_links:
|
||||||
continue
|
continue
|
||||||
|
page_unique = _variant_page_unique(product, variant)
|
||||||
|
if page_unique in seen:
|
||||||
|
continue
|
||||||
|
seen.add(page_unique)
|
||||||
serialized_products.append(_serialize_variant(request, product, variant))
|
serialized_products.append(_serialize_variant(request, product, variant))
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
|
|||||||
+186
-19
@@ -1,3 +1,4 @@
|
|||||||
|
import re
|
||||||
from .models import ProductModel
|
from .models import ProductModel
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
@@ -6,8 +7,8 @@ from .models import *
|
|||||||
from .serializers import *
|
from .serializers import *
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from django.db.models import Q, Value
|
from django.db.models import Q, Value, Case, When, FloatField, F, CharField, Func
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce, Length
|
||||||
from django.contrib.postgres.search import TrigramSimilarity
|
from django.contrib.postgres.search import TrigramSimilarity
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from rest_framework.permissions import IsAuthenticatedOrReadOnly
|
from rest_framework.permissions import IsAuthenticatedOrReadOnly
|
||||||
@@ -21,6 +22,179 @@ from home.models import ShowCaseSlider
|
|||||||
from home.serializers import ShowCaseSliderSerialzier
|
from home.serializers import ShowCaseSliderSerialzier
|
||||||
from order.models import Cart, CartItem
|
from order.models import Cart, CartItem
|
||||||
from django.db.models import Min, Max, Value
|
from django.db.models import Min, Max, Value
|
||||||
|
|
||||||
|
|
||||||
|
_PERSIAN_CHAR_MAP = str.maketrans({
|
||||||
|
# Arabic letters -> Persian equivalents
|
||||||
|
'ي': 'ی', 'ك': 'ک',
|
||||||
|
# Arabic ya/kaf presentation forms -> Persian
|
||||||
|
'ﻱ': 'ی', 'ﻲ': 'ی', 'ﻳ': 'ی', 'ﻴ': 'ی',
|
||||||
|
'ﻙ': 'ک', 'ﻚ': 'ک', 'ﻛ': 'ک', 'ﻜ': 'ک',
|
||||||
|
# Alef variants -> bare alef (so "ایفون" matches "آیفون")
|
||||||
|
'آ': 'ا', 'أ': 'ا', 'إ': 'ا', 'ٱ': 'ا',
|
||||||
|
# Hamza on waw/ya -> bare letter
|
||||||
|
'ؤ': 'و',
|
||||||
|
'ئ': 'ی',
|
||||||
|
# Ta marbuta / he variants -> he
|
||||||
|
'ة': 'ه', 'ۀ': 'ه',
|
||||||
|
'ﻩ': 'ه', 'ﻪ': 'ه', 'ﻫ': 'ه', 'ﻬ': 'ه',
|
||||||
|
# Tatweel - drop
|
||||||
|
'ـ': '',
|
||||||
|
# Tashkeel (diacritics) - drop
|
||||||
|
'ً': '', 'ٌ': '', 'ٍ': '', 'َ': '', 'ُ': '', 'ِ': '', 'ّ': '', 'ْ': '',
|
||||||
|
# Zero-width / direction marks
|
||||||
|
'': ' ', '': ' ',
|
||||||
|
'': '', '': '',
|
||||||
|
# Arabic-Indic / Persian digits -> ASCII
|
||||||
|
'۰': '0', '۱': '1', '۲': '2', '۳': '3', '۴': '4',
|
||||||
|
'۵': '5', '۶': '6', '۷': '7', '۸': '8', '۹': '9',
|
||||||
|
'٠': '0', '١': '1', '٢': '2', '٣': '3', '٤': '4',
|
||||||
|
'٥': '5', '٦': '6', '٧': '7', '٨': '8', '٩': '9',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_search_text(text):
|
||||||
|
"""Normalize a search string to handle Persian/Arabic variants, ZWNJ, and case."""
|
||||||
|
if not text:
|
||||||
|
return ''
|
||||||
|
return re.sub(r'\s+', ' ', text.translate(_PERSIAN_CHAR_MAP)).strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
# SQL-side equivalent of _PERSIAN_CHAR_MAP for PostgreSQL translate().
|
||||||
|
# Each char at position i in FROM is replaced by char at position i in TO;
|
||||||
|
# chars past len(TO) are deleted entirely. This must mirror the Python map so
|
||||||
|
# stored values and query strings normalize to the same form.
|
||||||
|
_SQL_NORM_FROM = (
|
||||||
|
'يك' # Arabic ya/kaf -> Persian
|
||||||
|
'ﻱﻲﻳﻴ' # Arabic ya presentation forms
|
||||||
|
'ﻙﻚﻛﻜ' # Arabic kaf presentation forms
|
||||||
|
'آأإٱ' # alef variants
|
||||||
|
'ؤ' # waw with hamza
|
||||||
|
'ئ' # ya with hamza
|
||||||
|
'ةۀ' # ta marbuta / he with hamza
|
||||||
|
'ﻩﻪﻫﻬ' # he presentation forms
|
||||||
|
'' # ZWNJ, ZWJ -> space
|
||||||
|
'۰۱۲۳۴۵۶۷۸۹' # Persian digits
|
||||||
|
'٠١٢٣٤٥٦٧٨٩' # Arabic-Indic digits
|
||||||
|
# Deletions (no matching char in TO):
|
||||||
|
'ـ' # tatweel
|
||||||
|
'' # LRM, RLM
|
||||||
|
'ًٌٍَُِّْ' # tashkeel
|
||||||
|
)
|
||||||
|
_SQL_NORM_TO = (
|
||||||
|
'یک'
|
||||||
|
'یییی'
|
||||||
|
'کککک'
|
||||||
|
'اااا'
|
||||||
|
'و'
|
||||||
|
'ی'
|
||||||
|
'هه'
|
||||||
|
'هههه'
|
||||||
|
' '
|
||||||
|
'0123456789'
|
||||||
|
'0123456789'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def NormalizePersian(expression):
|
||||||
|
"""SQL expression that calls the ``normalize_persian(text)`` Postgres function.
|
||||||
|
|
||||||
|
The function (defined in migration 0076) computes ``lower(translate(t, FROM, TO))``
|
||||||
|
and is marked IMMUTABLE so GIN trigram indexes on ``normalize_persian(name)``
|
||||||
|
etc. can be matched by the planner. Calling the function (instead of inlining
|
||||||
|
translate/lower) is what lets queries use those indexes — otherwise every
|
||||||
|
search is a full sequential scan.
|
||||||
|
"""
|
||||||
|
return Func(expression, function='normalize_persian', output_field=CharField())
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_product_search(queryset, search_query):
|
||||||
|
"""Filter and rank a Product queryset by a (possibly Persian) search query.
|
||||||
|
|
||||||
|
Returns (queryset, normalized_query). The queryset is annotated with
|
||||||
|
``similarity`` so callers can ``order_by('-similarity', ...)``. When no
|
||||||
|
product strictly matches, falls back to a looser similarity-based filter
|
||||||
|
so the user sees suggestions instead of an empty page.
|
||||||
|
"""
|
||||||
|
normalized_query = _normalize_search_text(search_query) if search_query else ''
|
||||||
|
if not normalized_query:
|
||||||
|
return queryset, ''
|
||||||
|
|
||||||
|
tokens = [t for t in normalized_query.split(' ') if len(t) >= 2]
|
||||||
|
|
||||||
|
annotated = queryset.annotate(
|
||||||
|
norm_name=NormalizePersian('name'),
|
||||||
|
norm_keywords=NormalizePersian(Coalesce('meta_keywords', Value(''))),
|
||||||
|
norm_category=NormalizePersian(Coalesce('category__name', Value(''))),
|
||||||
|
norm_desc=NormalizePersian(Coalesce('description', Value(''))),
|
||||||
|
).annotate(
|
||||||
|
name_sim=TrigramSimilarity(F('norm_name'), normalized_query),
|
||||||
|
keywords_sim=TrigramSimilarity(F('norm_keywords'), normalized_query),
|
||||||
|
category_sim=TrigramSimilarity(F('norm_category'), normalized_query),
|
||||||
|
desc_sim=TrigramSimilarity(F('norm_desc'), normalized_query),
|
||||||
|
).annotate(
|
||||||
|
# Word-boundary aware bonuses. The space-padded variants are what make
|
||||||
|
# "چای" rank above "چایساز" — the former matches "چای " (word boundary)
|
||||||
|
# while the latter only matches the glued prefix.
|
||||||
|
#
|
||||||
|
# Uses case-sensitive lookups (__contains, not __icontains) because both
|
||||||
|
# sides are already lowercased: __icontains would wrap the expression in
|
||||||
|
# UPPER(...) and break the GIN trigram index match.
|
||||||
|
match_bonus=Case(
|
||||||
|
When(norm_name__exact=normalized_query, then=Value(10.0)),
|
||||||
|
When(norm_name__startswith=normalized_query + ' ', then=Value(6.0)),
|
||||||
|
When(norm_name__startswith=normalized_query, then=Value(3.5)),
|
||||||
|
When(norm_name__contains=' ' + normalized_query + ' ', then=Value(3.0)),
|
||||||
|
When(norm_name__contains=' ' + normalized_query, then=Value(2.5)),
|
||||||
|
When(norm_name__contains=normalized_query + ' ', then=Value(2.5)),
|
||||||
|
When(norm_name__contains=normalized_query, then=Value(1.5)),
|
||||||
|
default=Value(0.0),
|
||||||
|
output_field=FloatField(),
|
||||||
|
)
|
||||||
|
).annotate(
|
||||||
|
similarity=(
|
||||||
|
F('match_bonus')
|
||||||
|
+ F('name_sim') * Value(2.0)
|
||||||
|
+ F('keywords_sim') * Value(0.8)
|
||||||
|
+ F('category_sim') * Value(0.4)
|
||||||
|
+ F('desc_sim') * Value(0.15)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if tokens:
|
||||||
|
# Token AND filter. Limited to fields we have GIN trigram indexes for
|
||||||
|
# (name, keywords, category.name in migration 0076) — including
|
||||||
|
# description or slug here would force a sequential scan on the OR
|
||||||
|
# branch and undo the index speedup. Description still contributes via
|
||||||
|
# ``desc_sim`` to ranking on the already-narrowed result set.
|
||||||
|
token_filter = Q()
|
||||||
|
for token in tokens:
|
||||||
|
token_filter &= (
|
||||||
|
Q(norm_name__contains=token)
|
||||||
|
| Q(norm_keywords__contains=token)
|
||||||
|
| Q(norm_category__contains=token)
|
||||||
|
)
|
||||||
|
strict_filter = (
|
||||||
|
token_filter
|
||||||
|
| Q(name_sim__gte=0.45)
|
||||||
|
| Q(keywords_sim__gte=0.5)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
strict_filter = Q(name_sim__gte=0.4) | Q(keywords_sim__gte=0.4)
|
||||||
|
|
||||||
|
strict_products = annotated.filter(strict_filter).distinct()
|
||||||
|
if strict_products.exists():
|
||||||
|
return strict_products, normalized_query
|
||||||
|
|
||||||
|
# No strict matches — relax thresholds so the user gets "similar"
|
||||||
|
# suggestions instead of an empty result page.
|
||||||
|
loose_filter = (
|
||||||
|
Q(name_sim__gte=0.18)
|
||||||
|
| Q(keywords_sim__gte=0.22)
|
||||||
|
| Q(category_sim__gte=0.3)
|
||||||
|
| Q(match_bonus__gt=0)
|
||||||
|
)
|
||||||
|
return annotated.filter(loose_filter).distinct(), normalized_query
|
||||||
# class APIView(APIView):
|
# class APIView(APIView):
|
||||||
# def __init__(self, *args, **kwargs):
|
# def __init__(self, *args, **kwargs):
|
||||||
# super().__init__(*args, **kwargs)
|
# super().__init__(*args, **kwargs)
|
||||||
@@ -324,18 +498,9 @@ class AllProductsView(APIView):
|
|||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
# Search
|
# Search (Persian-aware, with typo tolerance + similar-results fallback)
|
||||||
search_query = request.query_params.get('search')
|
search_query = request.query_params.get('search')
|
||||||
if search_query:
|
products, normalized_query = _apply_product_search(products, search_query)
|
||||||
products = products.annotate(
|
|
||||||
similarity=(
|
|
||||||
TrigramSimilarity('name', search_query) +
|
|
||||||
TrigramSimilarity(
|
|
||||||
Coalesce('description', Value('')),
|
|
||||||
search_query
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).filter(similarity__gt=0.1)
|
|
||||||
|
|
||||||
# Price annotation (IMPORTANT for sorting)
|
# Price annotation (IMPORTANT for sorting)
|
||||||
products = products.annotate(
|
products = products.annotate(
|
||||||
@@ -376,8 +541,10 @@ class AllProductsView(APIView):
|
|||||||
|
|
||||||
elif sort_by in ['price', '-price']:
|
elif sort_by in ['price', '-price']:
|
||||||
products = products.order_by('min_price' if sort_by == 'price' else '-min_price')
|
products = products.order_by('min_price' if sort_by == 'price' else '-min_price')
|
||||||
elif search_query:
|
elif normalized_query:
|
||||||
products = products.order_by('-similarity', 'name')
|
# Tie-break on shorter name: ensures "چای" outranks "چای ساز"
|
||||||
|
# when their bonus-adjusted similarities are close.
|
||||||
|
products = products.order_by('-similarity', Length('norm_name'), 'name')
|
||||||
else:
|
else:
|
||||||
products = products.order_by('name')
|
products = products.order_by('name')
|
||||||
|
|
||||||
@@ -522,11 +689,9 @@ class ShowCaseProductsView(APIView):
|
|||||||
if has_discount:
|
if has_discount:
|
||||||
products = products.filter(variants__discount__gt=0).distinct()
|
products = products.filter(variants__discount__gt=0).distinct()
|
||||||
|
|
||||||
# Search filter
|
# Search filter (Persian-aware, with typo tolerance + similar-results fallback)
|
||||||
search_query = request.query_params.get('search', None)
|
search_query = request.query_params.get('search', None)
|
||||||
if search_query:
|
products, normalized_query = _apply_product_search(products, search_query)
|
||||||
products = products.filter(Q(name__icontains=search_query) | Q(
|
|
||||||
description__icontains=search_query))
|
|
||||||
|
|
||||||
# Price filters
|
# Price filters
|
||||||
price_gte = request.query_params.get('price_gte', None)
|
price_gte = request.query_params.get('price_gte', None)
|
||||||
@@ -543,6 +708,8 @@ class ShowCaseProductsView(APIView):
|
|||||||
sort_by = request.query_params.get('sort', None)
|
sort_by = request.query_params.get('sort', None)
|
||||||
if sort_by in ['name', '-name', 'created_at', '-created_at']:
|
if sort_by in ['name', '-name', 'created_at', '-created_at']:
|
||||||
products = products.order_by(sort_by)
|
products = products.order_by(sort_by)
|
||||||
|
elif normalized_query:
|
||||||
|
products = products.order_by('-similarity', Length('norm_name'), 'name')
|
||||||
else:
|
else:
|
||||||
products = products.order_by('name')
|
products = products.order_by('name')
|
||||||
|
|
||||||
|
|||||||
+5
-4
@@ -17,8 +17,7 @@ services:
|
|||||||
|
|
||||||
django:
|
django:
|
||||||
container_name: shop_backend
|
container_name: shop_backend
|
||||||
build:
|
image: fix_update_bank:latest
|
||||||
context: ./backend
|
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -108,8 +107,10 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
container_name: hshop_redis
|
container_name: hshop_redis
|
||||||
image: redis:alpine
|
image: redis:alpine
|
||||||
ports:
|
command: >
|
||||||
- "6379:6379"
|
redis-server
|
||||||
|
--maxmemory 512mb
|
||||||
|
--maxmemory-policy volatile-lru
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
@@ -113,11 +113,15 @@ const onSwiper = (swiper: SwiperClass) => {
|
|||||||
</Swiper>
|
</Swiper>
|
||||||
</div>
|
</div>
|
||||||
<template #fallback>
|
<template #fallback>
|
||||||
<div class="w-full grid grid-cols-3 sm:grid-cols-4 gap-6">
|
<div class="w-full flex sm:grid justify-between sm:grid-cols-4 gap-6">
|
||||||
<div class="bg-neutral-100 !size-full !rounded-2xl !aspect-square" />
|
<div
|
||||||
<div class="bg-neutral-100 !size-full !rounded-2xl !aspect-square" />
|
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 !size-full !rounded-2xl !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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
|
||||||
// types
|
// types
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
rate: number
|
rate: number;
|
||||||
}
|
haveRate?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
// props
|
// props
|
||||||
|
|
||||||
defineProps<Props>();
|
defineProps<Props>();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="typo-p-sm font-normal translate-y-px">
|
<span class="typo-p-sm font-normal translate-y-px">
|
||||||
|
<template v-if="haveRate">
|
||||||
{{ rate }}
|
{{ rate }}
|
||||||
|
</template>
|
||||||
|
<template v-else> ( بدون نظر ) </template>
|
||||||
</span>
|
</span>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<Icon
|
<Icon
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex items-center gap-6">
|
|
||||||
<span class="typo-p-md text-black">
|
|
||||||
اشتراک گذاری:
|
|
||||||
</span>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<NuxtLink>
|
|
||||||
<Icon name="ci:instagram" class="**:stroke-slate-500 size-6" />
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink>
|
|
||||||
<Icon name="ci:facebook" class="**:stroke-slate-500 size-6" />
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink>
|
|
||||||
<Icon name="ci:tiktok" class="**:stroke-slate-500 size-6" />
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink>
|
|
||||||
<Icon name="ci:youtube" class="**:stroke-slate-500 size-6" />
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup lang="ts">
|
|
||||||
</script>
|
|
||||||
@@ -72,6 +72,7 @@ const changeSlide = (id: number) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative w-full">
|
<div class="relative w-full">
|
||||||
|
<ClientOnly>
|
||||||
<Swiper
|
<Swiper
|
||||||
:slides-per-view="3"
|
:slides-per-view="3"
|
||||||
:space-between="20"
|
:space-between="20"
|
||||||
@@ -111,6 +112,14 @@ const changeSlide = (id: number) => {
|
|||||||
</div>
|
</div>
|
||||||
</SwiperSlide>
|
</SwiperSlide>
|
||||||
</Swiper>
|
</Swiper>
|
||||||
|
<template #fallback>
|
||||||
|
<div class="w-full grid grid-cols-3 gap-6">
|
||||||
|
<div class="bg-neutral-100 size-full rounded-2xl aspect-square" />
|
||||||
|
<div class="bg-neutral-100 size-full rounded-2xl aspect-square" />
|
||||||
|
<div class="bg-neutral-100 size-full rounded-2xl aspect-square" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="slides.length > 3"
|
v-if="slides.length > 3"
|
||||||
|
|||||||
@@ -42,8 +42,12 @@ const onSlideChange = (swiper: SwiperClass) => {
|
|||||||
<span class="text-white typo-h-6 md:typo-h-5 lg:typo-h-4 min-[2000px]:typo-h-2"> دسته بندی ها </span>
|
<span class="text-white typo-h-6 md:typo-h-5 lg:typo-h-4 min-[2000px]:typo-h-2"> دسته بندی ها </span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full mt-44 lg:mt-64 relative">
|
<div
|
||||||
|
class="w-full relative"
|
||||||
|
:class="swiper_instance ? 'mt-44 lg:mt-64' : 'mt-24'"
|
||||||
|
>
|
||||||
<NuxtImg
|
<NuxtImg
|
||||||
|
v-if="swiper_instance"
|
||||||
class="aspect-square w-[210px] sm:w-[240px] md:w-[300px] lg:w-[350px] 2xl:w-[420px] translate-y-[-136px] sm:translate-y-[-156px] md:translate-y-[-195px] lg:translate-y-[-228px] 2xl:translate-y-[-273px] absolute left-1/2 -translate-x-1/2 z-10"
|
class="aspect-square w-[210px] sm:w-[240px] md:w-[300px] lg:w-[350px] 2xl:w-[420px] translate-y-[-136px] sm:translate-y-[-156px] md:translate-y-[-195px] lg:translate-y-[-228px] 2xl:translate-y-[-273px] absolute left-1/2 -translate-x-1/2 z-10"
|
||||||
:style="{
|
:style="{
|
||||||
filter: 'drop-shadow(0px 0px 20px rgba(0, 0, 0, 0.4))',
|
filter: 'drop-shadow(0px 0px 20px rgba(0, 0, 0, 0.4))',
|
||||||
@@ -52,6 +56,8 @@ const onSlideChange = (swiper: SwiperClass) => {
|
|||||||
fetch-priority="low"
|
fetch-priority="low"
|
||||||
src="/img/heymlz/heymlz-category-seat.gif"
|
src="/img/heymlz/heymlz-category-seat.gif"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ClientOnly>
|
||||||
<Swiper
|
<Swiper
|
||||||
:loop="true"
|
:loop="true"
|
||||||
:centered-slides="true"
|
:centered-slides="true"
|
||||||
@@ -124,8 +130,22 @@ const onSlideChange = (swiper: SwiperClass) => {
|
|||||||
</SwiperSlide>
|
</SwiperSlide>
|
||||||
</Swiper>
|
</Swiper>
|
||||||
|
|
||||||
|
<template #fallback>
|
||||||
<div
|
<div
|
||||||
v-if="!swiper_instance?.isBeginning"
|
class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 min-[3000px]:grid-cols-6 gap-12 lg:gap-20 px-12 lg:px-20"
|
||||||
|
>
|
||||||
|
<div class="w-full aspect-square bg-neutral-800/75 rounded-150"></div>
|
||||||
|
<div class="w-full aspect-square bg-neutral-800/75 rounded-150"></div>
|
||||||
|
<div class="w-full aspect-square bg-neutral-800/75 rounded-150 max-md:hidden"></div>
|
||||||
|
<div class="w-full aspect-square bg-neutral-800/75 rounded-150 max-xl:hidden"></div>
|
||||||
|
<div class="w-full aspect-square bg-neutral-800/75 rounded-150 max-[3000px]:hidden"></div>
|
||||||
|
<div class="w-full aspect-square bg-neutral-800/75 rounded-150 max-[3000px]:hidden"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="swiper_instance && !swiper_instance?.isBeginning"
|
||||||
@click="swiper_instance?.slidePrev()"
|
@click="swiper_instance?.slidePrev()"
|
||||||
:style="{
|
:style="{
|
||||||
right: `calc(50% - ${slideWidth / 2}px - 20px)`,
|
right: `calc(50% - ${slideWidth / 2}px - 20px)`,
|
||||||
@@ -139,7 +159,7 @@ const onSlideChange = (swiper: SwiperClass) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="!swiper_instance?.isEnd"
|
v-if="swiper_instance && !swiper_instance?.isEnd"
|
||||||
@click="swiper_instance?.slideNext()"
|
@click="swiper_instance?.slideNext()"
|
||||||
:style="{
|
:style="{
|
||||||
left: `calc(50% - ${slideWidth / 2}px - 20px)`,
|
left: `calc(50% - ${slideWidth / 2}px - 20px)`,
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ const limitedComments = computed(() => {
|
|||||||
v-if="!!comments"
|
v-if="!!comments"
|
||||||
class="bg-slate-50"
|
class="bg-slate-50"
|
||||||
>
|
>
|
||||||
<div class="flex relative gap-8 my-42 container max-lg:flex-col">
|
<div class="flex relative gap-8 my-24 sm:my-42 container max-lg:flex-col">
|
||||||
<div
|
<div
|
||||||
class="sticky top-0 flex flex-col gap-6 lg:min-w-100 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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const { selectedVariant } = inject("productVariant") as ProductVariantProvideTyp
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="w-full container pt-20 flex flex-col gap-y-[1.5rem]">
|
<section class="w-full container sm:pt-20 flex flex-col gap-y-6">
|
||||||
<div class="w-full flex">
|
<div class="w-full flex">
|
||||||
<span class="text-black max-lg:hidden typo-h-4 mb-4"> جزئیات محصول </span>
|
<span class="text-black max-lg:hidden typo-h-4 mb-4"> جزئیات محصول </span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
// imports
|
||||||
|
|
||||||
|
import { useAuth } from "~/composables/api/auth/useAuth";
|
||||||
|
import useGetProduct from "~/composables/api/product/useGetProduct";
|
||||||
|
import useSaveProduct from "~/composables/api/product/useSaveProduct";
|
||||||
|
import { useToast } from "~/composables/global/useToast";
|
||||||
|
import { QUERY_KEYS } from "~/constants";
|
||||||
|
|
||||||
|
// states
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const id = route.params.id as string | undefined;
|
||||||
|
|
||||||
|
const { $queryClient: queryClient } = useNuxtApp();
|
||||||
|
|
||||||
|
const { token } = useAuth();
|
||||||
|
const { addToast } = useToast();
|
||||||
|
|
||||||
|
const { mutateAsync: saveProduct, isPending: isSaveProductPending } = useSaveProduct();
|
||||||
|
const { data: product, refetch: refetchProduct, isFetching: isFetchingPending } = useGetProduct(id);
|
||||||
|
|
||||||
|
// methods
|
||||||
|
|
||||||
|
const saveProductHandler = async () => {
|
||||||
|
if (!!token.value) {
|
||||||
|
await saveProduct({ product_slug: product.value!.slug });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.product] });
|
||||||
|
} else {
|
||||||
|
addToast({
|
||||||
|
options: { status: "info" },
|
||||||
|
message: "برای ذخیره کردن لطفا وارد شوید.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
@click="saveProductHandler"
|
||||||
|
:disabled="isSaveProductPending || isFetchingPending"
|
||||||
|
class="px-2 sm:px-3 py-2 flex items-center gap-2 bg-slate-50 border-slate-200 border rounded-lg flex-center"
|
||||||
|
>
|
||||||
|
<span class="typo-label-sm max-sm:hidden"> ذخیره </span>
|
||||||
|
|
||||||
|
<Icon
|
||||||
|
v-if="isSaveProductPending || isFetchingPending"
|
||||||
|
name="ci:svg-spinners-180-ring-with-bg"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Icon
|
||||||
|
v-else
|
||||||
|
:class="product?.added_to_favorites ? '**:fill-blue-400' : ''"
|
||||||
|
:name="product?.added_to_favorites ? 'bi-bookmark-fill' : 'ci:bi-bookmark'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
// imports
|
||||||
|
|
||||||
|
import { useToast } from "~/composables/global/useToast";
|
||||||
|
|
||||||
|
// types
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
product: Product;
|
||||||
|
};
|
||||||
|
|
||||||
|
// props
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const { product } = toRefs(props);
|
||||||
|
|
||||||
|
// states
|
||||||
|
|
||||||
|
const { addToast } = useToast();
|
||||||
|
|
||||||
|
// methods
|
||||||
|
|
||||||
|
const shareProduct = async () => {
|
||||||
|
const shareData = {
|
||||||
|
title: product.value.name,
|
||||||
|
text: `لینک اشتراک گذاری محصول ${product.value.name}`,
|
||||||
|
url: window.location.href,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Native share
|
||||||
|
if (navigator.share) {
|
||||||
|
try {
|
||||||
|
await navigator.share(shareData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Share canceled or failed", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback → copy link
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(shareData.url);
|
||||||
|
|
||||||
|
addToast({
|
||||||
|
message: "لینک کالا کپی شد !",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Clipboard failed", error);
|
||||||
|
addToast({
|
||||||
|
options: { status: "error" },
|
||||||
|
message: "کپی لینک کالا با خطا مواجه شد !",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
@click="shareProduct"
|
||||||
|
class="px-2 py-2 flex items-center gap-2 bg-slate-50 border-slate-200 border rounded-lg flex-center"
|
||||||
|
>
|
||||||
|
<span class="typo-label-sm max-sm:hidden"> ارسال </span>
|
||||||
|
|
||||||
|
<Icon
|
||||||
|
name="ci:bi-share"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
@@ -5,8 +5,6 @@ import useGetProduct from "~/composables/api/product/useGetProduct";
|
|||||||
import type { ProductVariantProvideType } from "~/pages/product/[id].vue";
|
import type { ProductVariantProvideType } from "~/pages/product/[id].vue";
|
||||||
import useAddCartItem from "~/composables/api/orders/useAddCartItem";
|
import useAddCartItem from "~/composables/api/orders/useAddCartItem";
|
||||||
import { useAuth } from "~/composables/api/auth/useAuth";
|
import { useAuth } from "~/composables/api/auth/useAuth";
|
||||||
import useSaveProduct from "~/composables/api/product/useSaveProduct";
|
|
||||||
import { QUERY_KEYS } from "~/constants";
|
|
||||||
|
|
||||||
// state
|
// state
|
||||||
|
|
||||||
@@ -14,11 +12,9 @@ const route = useRoute();
|
|||||||
const id = route.params.id as string | undefined;
|
const id = route.params.id as string | undefined;
|
||||||
|
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
const { $queryClient: queryClient } = useNuxtApp();
|
|
||||||
|
|
||||||
const { data: product, refetch: refetchProduct, isFetching: isFetchingPending } = useGetProduct(id);
|
const { data: product, refetch: refetchProduct, isFetching: isFetchingPending } = useGetProduct(id);
|
||||||
const { mutateAsync: addCartItem, isPending: isAddCartItemPending } = useAddCartItem();
|
const { mutateAsync: addCartItem, isPending: isAddCartItemPending } = useAddCartItem();
|
||||||
const { mutateAsync: saveProduct, isPending: isSaveProductPending } = useSaveProduct();
|
|
||||||
|
|
||||||
const selectedVariantId = ref(product.value!.variants[0].id);
|
const selectedVariantId = ref(product.value!.variants[0].id);
|
||||||
const selectedQuantity = ref(1);
|
const selectedQuantity = ref(1);
|
||||||
@@ -40,11 +36,6 @@ const addItemToCart = async () => {
|
|||||||
await refetchProduct();
|
await refetchProduct();
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveProductHandler = async () => {
|
|
||||||
await saveProduct({ product_slug: product.value!.slug });
|
|
||||||
await queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.product] });
|
|
||||||
};
|
|
||||||
|
|
||||||
// watch
|
// watch
|
||||||
|
|
||||||
watch([selectedVariantId, product], ([selectedVariantId, product]) => {
|
watch([selectedVariantId, product], ([selectedVariantId, product]) => {
|
||||||
@@ -77,31 +68,32 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex max-lg:flex-col lg:gap-12 xl:gap-16 container pt-[5rem] pb-28">
|
<div class="flex max-lg:flex-col lg:gap-12 xl:gap-16 container pt-8 sm:pt-20 pb-28">
|
||||||
<div class="flex flex-col gap-3 lg:hidden">
|
<div class="flex flex-col gap-3 lg:hidden">
|
||||||
<div class="flex items-center justify-between w-full">
|
<div class="flex items-center justify-between w-full">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<NuxtLink
|
||||||
|
to="#"
|
||||||
|
class="typo-label-sm"
|
||||||
|
>
|
||||||
|
محصولات
|
||||||
|
</NuxtLink>
|
||||||
|
<Icon
|
||||||
|
name="ci:bi-chevron-left"
|
||||||
|
size="14"
|
||||||
|
/>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="#"
|
to="#"
|
||||||
class="typo-label-sm"
|
class="typo-label-sm"
|
||||||
>
|
>
|
||||||
{{ product!.category.name }}
|
{{ product!.category.name }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<button
|
</div>
|
||||||
@click="saveProductHandler"
|
|
||||||
:disabled="isSaveProductPending || isFetchingPending || !token"
|
|
||||||
class="size-10 bg-slate-50 border-slate-200 border rounded-lg flex-center"
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
v-if="isSaveProductPending || isFetchingPending"
|
|
||||||
name="ci:svg-spinners-180-ring-with-bg"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Icon
|
<div class="flex items-center gap-2">
|
||||||
v-else
|
<ShareButton :product="product!" />
|
||||||
:class="product?.added_to_favorites ? '**:fill-blue-400' : ''"
|
<SaveButton />
|
||||||
:name="product?.added_to_favorites ? 'bi-bookmark-fill' : 'ci:bi-bookmark'"
|
</div>
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<h1 class="typo-h-6 xs:typo-h-5 sm:typo-h-4 lg:typo-h-2">
|
<h1 class="typo-h-6 xs:typo-h-5 sm:typo-h-4 lg:typo-h-2">
|
||||||
{{ product!.name }}
|
{{ product!.name }}
|
||||||
@@ -136,7 +128,10 @@ watch(
|
|||||||
<span class="max-sm:hidden"> تخفیف درصد </span>
|
<span class="max-sm:hidden"> تخفیف درصد </span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Rating :rate="product!.rating" />
|
<Rating
|
||||||
|
:rate="product!.rating"
|
||||||
|
:have-rate="product!.rating !== 0"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Slider
|
<Slider
|
||||||
@@ -146,28 +141,28 @@ watch(
|
|||||||
/>
|
/>
|
||||||
<div class="lg:w-1/2 flex flex-col gap-3 mt-12">
|
<div class="lg:w-1/2 flex flex-col gap-3 mt-12">
|
||||||
<div class="flex items-center justify-between w-full max-lg:hidden">
|
<div class="flex items-center justify-between w-full max-lg:hidden">
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<NuxtLink
|
||||||
|
to="#"
|
||||||
|
class="typo-label-sm"
|
||||||
|
>
|
||||||
|
محصولات
|
||||||
|
</NuxtLink>
|
||||||
|
<Icon
|
||||||
|
name="ci:bi-chevron-left"
|
||||||
|
size="14"
|
||||||
|
/>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="#"
|
to="#"
|
||||||
class="typo-label-sm"
|
class="typo-label-sm"
|
||||||
>
|
>
|
||||||
{{ product!.category.name }}
|
{{ product!.category.name }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<button
|
</div>
|
||||||
@click="saveProductHandler"
|
<div class="flex items-center gap-2">
|
||||||
:disabled="isSaveProductPending || isFetchingPending || !token"
|
<ShareButton :product="product!" />
|
||||||
class="size-10 bg-slate-50 border-slate-200 border rounded-lg flex-center"
|
<SaveButton />
|
||||||
>
|
</div>
|
||||||
<Icon
|
|
||||||
v-if="isSaveProductPending || isFetchingPending"
|
|
||||||
name="ci:svg-spinners-180-ring-with-bg"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Icon
|
|
||||||
v-else
|
|
||||||
:class="product?.added_to_favorites ? '**:fill-blue-400' : ''"
|
|
||||||
:name="product?.added_to_favorites ? 'bi-bookmark-fill' : 'ci:bi-bookmark'"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<h1 class="typo-h-4 xl:typo-h-3 max-lg:hidden">
|
<h1 class="typo-h-4 xl:typo-h-3 max-lg:hidden">
|
||||||
{{ product!.name }}
|
{{ product!.name }}
|
||||||
@@ -205,12 +200,14 @@ watch(
|
|||||||
|
|
||||||
<Rating
|
<Rating
|
||||||
:rate="product!.rating"
|
:rate="product!.rating"
|
||||||
|
:have-rate="product!.rating !== 0"
|
||||||
class="sm:hidden"
|
class="sm:hidden"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Rating
|
<Rating
|
||||||
:rate="product!.rating"
|
:rate="product!.rating"
|
||||||
|
:have-rate="product!.rating !== 0"
|
||||||
class="max-sm:hidden"
|
class="max-sm:hidden"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -338,8 +335,6 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<InfoCard />
|
<InfoCard />
|
||||||
|
|
||||||
<Share />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ProductDescription
|
<ProductDescription
|
||||||
|
|||||||
@@ -73,6 +73,6 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<ProductComments :product="product!" />
|
<ProductComments :product="product!" />
|
||||||
|
|
||||||
<ChatButton :showChatButton="showChatButton" />
|
<!-- <ChatButton :showChatButton="showChatButton" /> -->
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 16 16"><!-- Icon from Bootstrap Icons by The Bootstrap Authors - https://github.com/twbs/icons/blob/main/LICENSE.md --><path fill="currentColor" d="M13.5 1a1.5 1.5 0 1 0 0 3a1.5 1.5 0 0 0 0-3M11 2.5a2.5 2.5 0 1 1 .603 1.628l-6.718 3.12a2.5 2.5 0 0 1 0 1.504l6.718 3.12a2.5 2.5 0 1 1-.488.876l-6.718-3.12a2.5 2.5 0 1 1 0-3.256l6.718-3.12A2.5 2.5 0 0 1 11 2.5m-8.5 4a1.5 1.5 0 1 0 0 3a1.5 1.5 0 0 0 0-3m11 5.5a1.5 1.5 0 1 0 0 3a1.5 1.5 0 0 0 0-3"/></svg>
|
||||||
|
After Width: | Height: | Size: 528 B |
Reference in New Issue
Block a user