Compare commits
21 Commits
| 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
|
||||
warn_unsaved_form = True
|
||||
# list_per_page = 2
|
||||
actions_list = ['redirect_to_learn', 'update_products_price']
|
||||
actions_list = ['redirect_to_learn', 'update_products_price', 'resync_all_torob']
|
||||
list_display = ['display_image', 'shop__shop_name', 'view', 'rating', 'category', 'created_at' ,'show_in_website', ]
|
||||
fieldsets = (
|
||||
('فیلد های اصلی', {'fields': ('name', 'description', 'category', 'image', 'related_products','show_in_trends', 'show_in_most_viewed', 'show_in_lot_of_discount', 'show_in_top_seller', 'shop', 'show_in_bot', 'bot_banner'), "classes": ["tab"],}),
|
||||
@@ -497,6 +497,57 @@ class ProductModelAdmin(ProductAdminPermission, ModelAdmin, ImportExportModelAdm
|
||||
messages.success(request, f"قیمت {ProductVariant.objects.all().count()} تنوع محصول اپدیت شد")
|
||||
return redirect("admin:product_productmodel_changelist")
|
||||
|
||||
@action(description="ارسال مجدد همه محصولات به ترب")
|
||||
def resync_all_torob(self, request):
|
||||
from django.conf import settings
|
||||
from product.tasks import send_torob_product_webhook
|
||||
|
||||
if not getattr(settings, "TOROB_PRODUCT_WEBHOOK_TOKEN", None):
|
||||
messages.error(request, "توکن وبهوک ترب در تنظیمات وجود ندارد")
|
||||
return redirect("admin:product_productmodel_changelist")
|
||||
|
||||
product_ids = list(
|
||||
ProductModel.objects.exclude(slug__isnull=True).exclude(slug="").values_list("id", flat=True)
|
||||
)
|
||||
if not product_ids:
|
||||
messages.warning(request, "محصولی برای ارسال یافت نشد")
|
||||
return redirect("admin:product_productmodel_changelist")
|
||||
|
||||
chunk_size = 50
|
||||
queued = 0
|
||||
for start in range(0, len(product_ids), chunk_size):
|
||||
send_torob_product_webhook.delay(product_ids[start:start + chunk_size])
|
||||
queued += 1
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
f"{len(product_ids)} محصول در {queued} بسته برای ارسال به ترب در صف قرار گرفت",
|
||||
)
|
||||
return redirect("admin:product_productmodel_changelist")
|
||||
|
||||
def resync_selected_torob(self, request, queryset):
|
||||
from django.conf import settings
|
||||
from product.tasks import send_torob_product_webhook
|
||||
|
||||
if not getattr(settings, "TOROB_PRODUCT_WEBHOOK_TOKEN", None):
|
||||
messages.error(request, "توکن وبهوک ترب در تنظیمات وجود ندارد")
|
||||
return
|
||||
|
||||
product_ids = list(
|
||||
queryset.exclude(slug__isnull=True).exclude(slug="").values_list("id", flat=True)
|
||||
)
|
||||
if not product_ids:
|
||||
messages.warning(request, "محصول معتبری انتخاب نشد")
|
||||
return
|
||||
|
||||
chunk_size = 50
|
||||
for start in range(0, len(product_ids), chunk_size):
|
||||
send_torob_product_webhook.delay(product_ids[start:start + chunk_size])
|
||||
|
||||
messages.success(request, f"{len(product_ids)} محصول برای ارسال به ترب در صف قرار گرفت")
|
||||
|
||||
resync_selected_torob.short_description = "ارسال محصولات انتخاب شده به ترب"
|
||||
|
||||
def bulk_update_subcategory_action(self, request, queryset):
|
||||
"""اکشن برای تغییر دستهبندی چند محصول همزمان"""
|
||||
|
||||
@@ -535,7 +586,7 @@ class ProductModelAdmin(ProductAdminPermission, ModelAdmin, ImportExportModelAdm
|
||||
)
|
||||
|
||||
bulk_update_subcategory_action.short_description = "تغییر دستهبندی محصولات انتخاب شده"
|
||||
actions = ['bulk_update_subcategory_action']
|
||||
actions = ['bulk_update_subcategory_action', 'resync_selected_torob']
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("product", "0074_productmodel_updated_at_productvariant_updated_at"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="productvariant",
|
||||
name="guarantee",
|
||||
field=models.CharField(
|
||||
default="گرانتی اصالت و سلامت کالا",
|
||||
help_text="این مقدار به ترب ارسال میشود تا محصول بهعنوان نو طبقهبندی شود",
|
||||
max_length=200,
|
||||
verbose_name="گارانتی",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
Add an IMMUTABLE ``normalize_persian(text)`` SQL function and GIN trigram
|
||||
indexes that match it. Lets the search view filter and rank without doing a
|
||||
per-row ``translate()`` on a sequential scan — drops query time from seconds
|
||||
to tens of milliseconds.
|
||||
|
||||
The FROM/TO strings here MUST stay aligned with ``_SQL_NORM_FROM`` /
|
||||
``_SQL_NORM_TO`` in ``product/views.py``. If you change one, change the other
|
||||
and add a follow-up migration that recreates the function + indexes (Postgres
|
||||
matches expression indexes by exact SQL form, so a stale function would
|
||||
silently bypass the indexes).
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
# Mirror of product.views._SQL_NORM_FROM / _SQL_NORM_TO.
|
||||
_SQL_NORM_FROM = (
|
||||
'يك' # Arabic ya/kaf -> Persian
|
||||
'ﻱﻲﻳﻴ' # Arabic ya presentation forms
|
||||
'ﻙﻚﻛﻜ' # Arabic kaf presentation forms
|
||||
'آأإٱ' # alef variants
|
||||
'ؤ' # waw with hamza
|
||||
'ئ' # ya with hamza
|
||||
'ةۀ' # ta marbuta / he with hamza
|
||||
'ﻩﻪﻫﻬ' # he presentation forms
|
||||
'' # ZWNJ, ZWJ -> space
|
||||
'۰۱۲۳۴۵۶۷۸۹' # Persian digits
|
||||
'٠١٢٣٤٥٦٧٨٩' # Arabic-Indic digits
|
||||
# Deletions (no matching char in TO):
|
||||
'ـ' # tatweel
|
||||
'' # LRM, RLM
|
||||
'ًٌٍَُِّْ' # tashkeel
|
||||
)
|
||||
_SQL_NORM_TO = (
|
||||
'یک'
|
||||
'یییی'
|
||||
'کککک'
|
||||
'اااا'
|
||||
'و'
|
||||
'ی'
|
||||
'هه'
|
||||
'هههه'
|
||||
' '
|
||||
'0123456789'
|
||||
'0123456789'
|
||||
)
|
||||
|
||||
|
||||
def _pg_str(s):
|
||||
"""Quote a Python string as a PostgreSQL string literal."""
|
||||
return "'" + s.replace("'", "''") + "'"
|
||||
|
||||
|
||||
CREATE_FUNCTION_SQL = f"""
|
||||
CREATE OR REPLACE FUNCTION normalize_persian(t text) RETURNS text AS $$
|
||||
SELECT lower(translate(t, {_pg_str(_SQL_NORM_FROM)}, {_pg_str(_SQL_NORM_TO)}));
|
||||
$$ LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE;
|
||||
"""
|
||||
|
||||
DROP_FUNCTION_SQL = "DROP FUNCTION IF EXISTS normalize_persian(text);"
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("product", "0075_productvariant_guarantee"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunSQL(
|
||||
sql=CREATE_FUNCTION_SQL,
|
||||
reverse_sql=DROP_FUNCTION_SQL,
|
||||
),
|
||||
# GIN trigram indexes on the normalized expression. PostgreSQL matches
|
||||
# queries that use exactly ``normalize_persian(<col>)`` against these
|
||||
# indexes, so the views.py wrapper must call the SQL function (not
|
||||
# inline translate/lower) for the index to be used.
|
||||
migrations.RunSQL(
|
||||
sql=(
|
||||
"CREATE INDEX IF NOT EXISTS product_norm_name_trgm_idx "
|
||||
"ON product_productmodel "
|
||||
"USING gin (normalize_persian(name) gin_trgm_ops);"
|
||||
),
|
||||
reverse_sql="DROP INDEX IF EXISTS product_norm_name_trgm_idx;",
|
||||
),
|
||||
migrations.RunSQL(
|
||||
sql=(
|
||||
"CREATE INDEX IF NOT EXISTS product_norm_keywords_trgm_idx "
|
||||
"ON product_productmodel "
|
||||
"USING gin (normalize_persian(meta_keywords) gin_trgm_ops);"
|
||||
),
|
||||
reverse_sql="DROP INDEX IF EXISTS product_norm_keywords_trgm_idx;",
|
||||
),
|
||||
migrations.RunSQL(
|
||||
sql=(
|
||||
"CREATE INDEX IF NOT EXISTS subcategory_norm_name_trgm_idx "
|
||||
"ON product_subcategorymodel "
|
||||
"USING gin (normalize_persian(name) gin_trgm_ops);"
|
||||
),
|
||||
reverse_sql="DROP INDEX IF EXISTS subcategory_norm_name_trgm_idx;",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2 on 2026-06-02 05:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('product', '0076_normalize_persian_search'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='productvariant',
|
||||
name='guarantee',
|
||||
field=models.CharField(default='گارانتی اصالت و سلامت کالا', help_text='این مقدار به ترب ارسال می\u200cشود تا محصول به\u200cعنوان نو طبقه\u200cبندی شود', max_length=200, verbose_name='گارانتی'),
|
||||
),
|
||||
]
|
||||
@@ -147,7 +147,7 @@ class DollorModel(models.Model):
|
||||
def get_usd_price(self):
|
||||
try:
|
||||
api_usd = "https://api.nobitex.ir/v2/orderbook/USDTIRT"
|
||||
response = requests.get(api_usd)
|
||||
response = requests.get(api_usd, timeout=5)
|
||||
data = response.json()
|
||||
price = int(data["lastTradePrice"])
|
||||
price_in_usd = price / 10.0
|
||||
@@ -403,6 +403,10 @@ class ProductVariant(DirtyFieldsMixin, models.Model):
|
||||
discount = models.SmallIntegerField(default=0, verbose_name='درصد تخفیف', help_text='این درصد از قیمت نهایی محصول کسر میگردد')
|
||||
color = models.CharField(
|
||||
verbose_name='رنگ', max_length=7, blank=True, null=True)
|
||||
guarantee = models.CharField(
|
||||
max_length=200, default='گارانتی اصالت و سلامت کالا',
|
||||
verbose_name='گارانتی',
|
||||
help_text='این مقدار به ترب ارسال میشود تا محصول بهعنوان نو طبقهبندی شود')
|
||||
images = models.ManyToManyField(ProductImageModel, verbose_name='عکس ها')
|
||||
video = models.FileField(upload_to='product_videos/',
|
||||
blank=True, null=True, verbose_name='ویدیو')
|
||||
|
||||
+13
-10
@@ -17,13 +17,16 @@ TOROB_WEBHOOK_MIN_INTERVAL_SECONDS = 3.1
|
||||
TOROB_WEBHOOK_MAX_RETRIES = 3
|
||||
|
||||
|
||||
def _shop_product_url(product: ProductModel) -> str:
|
||||
def _shop_product_url(product: ProductModel, variant: ProductVariant | None = None) -> str:
|
||||
domain = getattr(settings, "DOMAIN", None) or getattr(settings, "API_DOMAIN", None) or ""
|
||||
if domain.startswith("http://") or domain.startswith("https://"):
|
||||
base = domain.rstrip("/")
|
||||
else:
|
||||
base = f"https://{domain}".rstrip("/") if domain else ""
|
||||
return f"{base}/product/{product.slug}/"
|
||||
url = f"{base}/product/{product.slug}/"
|
||||
if variant is not None:
|
||||
url = f"{url}?variant={variant.pk}"
|
||||
return url
|
||||
|
||||
|
||||
def _variant_page_unique(product: ProductModel, variant: ProductVariant) -> str:
|
||||
@@ -124,18 +127,18 @@ def send_torob_product_webhook(product_ids):
|
||||
if not product.slug:
|
||||
continue
|
||||
|
||||
page_url = _shop_product_url(product)
|
||||
parsed = urlparse(page_url)
|
||||
if not (parsed.scheme in {"http", "https"} and parsed.netloc):
|
||||
logger.warning("Skipping product %s due to invalid page_url: %s", product.pk, page_url)
|
||||
continue
|
||||
hosts.add(parsed.netloc.lower())
|
||||
|
||||
variants = list(product.variants.all())
|
||||
if not variants:
|
||||
continue
|
||||
|
||||
for variant in variants:
|
||||
variant_page_url = _shop_product_url(product, variant)
|
||||
parsed = urlparse(variant_page_url)
|
||||
if not (parsed.scheme in {"http", "https"} and parsed.netloc):
|
||||
logger.warning("Skipping variant %s due to invalid page_url: %s", variant.pk, variant_page_url)
|
||||
continue
|
||||
hosts.add(parsed.netloc.lower())
|
||||
|
||||
# Validate variant has images before sending to Torob
|
||||
# Per spec: image_links is required, so skip variants without images
|
||||
images = list(variant.images.all())
|
||||
@@ -148,7 +151,7 @@ def send_torob_product_webhook(product_ids):
|
||||
|
||||
items.append(
|
||||
{
|
||||
"page_url": page_url,
|
||||
"page_url": variant_page_url,
|
||||
"page_unique": _variant_page_unique(product, variant),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import jwt
|
||||
from django.conf import settings
|
||||
@@ -80,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}"
|
||||
|
||||
@@ -109,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]
|
||||
|
||||
@@ -140,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
|
||||
|
||||
|
||||
@@ -213,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),
|
||||
@@ -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),
|
||||
"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_name": product.shop.shop_name if product.shop else None,
|
||||
}
|
||||
|
||||
if old_price is not None and old_price > current_price:
|
||||
@@ -336,27 +347,49 @@ class TorobProductSyncView(APIView):
|
||||
for product in products
|
||||
}
|
||||
|
||||
ordered_products = []
|
||||
ordered_lookups: list[tuple[ProductModel, str | None]] = []
|
||||
for url in requested_urls:
|
||||
slug = _extract_slug_from_url(url)
|
||||
normalized_url = _normalize_url(url)
|
||||
variant_id = _extract_variant_id_from_url(url)
|
||||
product = product_by_slug.get(slug) if slug else None
|
||||
if product is None:
|
||||
product = product_by_url.get(normalized_url)
|
||||
if product is not None and product not in ordered_products:
|
||||
ordered_products.append(product)
|
||||
if product is None:
|
||||
continue
|
||||
ordered_lookups.append((product, variant_id))
|
||||
|
||||
serialized_products = []
|
||||
for product in ordered_products:
|
||||
seen: set[str] = set()
|
||||
for product, variant_id in ordered_lookups:
|
||||
variants = list(product.variants.all())
|
||||
if not variants:
|
||||
continue
|
||||
|
||||
if variant_id:
|
||||
variant = next((v for v in variants if str(v.pk) == variant_id), None)
|
||||
if not variant:
|
||||
continue
|
||||
image_links = _product_image_links(request, product, variant)
|
||||
if not image_links:
|
||||
continue
|
||||
page_unique = _variant_page_unique(product, variant)
|
||||
if page_unique in seen:
|
||||
continue
|
||||
seen.add(page_unique)
|
||||
serialized_products.append(_serialize_variant(request, product, variant))
|
||||
continue
|
||||
|
||||
variants.sort(key=_variant_sort_key)
|
||||
for variant in variants:
|
||||
image_links = _product_image_links(request, product, variant)
|
||||
# Skip variants without images as per spec requirement
|
||||
if not image_links:
|
||||
continue
|
||||
page_unique = _variant_page_unique(product, variant)
|
||||
if page_unique in seen:
|
||||
continue
|
||||
seen.add(page_unique)
|
||||
serialized_products.append(_serialize_variant(request, product, variant))
|
||||
|
||||
return Response(
|
||||
|
||||
+186
-19
@@ -1,3 +1,4 @@
|
||||
import re
|
||||
from .models import ProductModel
|
||||
from rest_framework import serializers
|
||||
from django.core.paginator import Paginator
|
||||
@@ -6,8 +7,8 @@ from .models import *
|
||||
from .serializers import *
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from django.db.models import Q, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.db.models import Q, Value, Case, When, FloatField, F, CharField, Func
|
||||
from django.db.models.functions import Coalesce, Length
|
||||
from django.contrib.postgres.search import TrigramSimilarity
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework.permissions import IsAuthenticatedOrReadOnly
|
||||
@@ -21,6 +22,179 @@ from home.models import ShowCaseSlider
|
||||
from home.serializers import ShowCaseSliderSerialzier
|
||||
from order.models import Cart, CartItem
|
||||
from django.db.models import Min, Max, Value
|
||||
|
||||
|
||||
_PERSIAN_CHAR_MAP = str.maketrans({
|
||||
# Arabic letters -> Persian equivalents
|
||||
'ي': 'ی', 'ك': 'ک',
|
||||
# Arabic ya/kaf presentation forms -> Persian
|
||||
'ﻱ': 'ی', 'ﻲ': 'ی', 'ﻳ': 'ی', 'ﻴ': 'ی',
|
||||
'ﻙ': 'ک', 'ﻚ': 'ک', 'ﻛ': 'ک', 'ﻜ': 'ک',
|
||||
# Alef variants -> bare alef (so "ایفون" matches "آیفون")
|
||||
'آ': 'ا', 'أ': 'ا', 'إ': 'ا', 'ٱ': 'ا',
|
||||
# Hamza on waw/ya -> bare letter
|
||||
'ؤ': 'و',
|
||||
'ئ': 'ی',
|
||||
# Ta marbuta / he variants -> he
|
||||
'ة': 'ه', 'ۀ': 'ه',
|
||||
'ﻩ': 'ه', 'ﻪ': 'ه', 'ﻫ': 'ه', 'ﻬ': 'ه',
|
||||
# Tatweel - drop
|
||||
'ـ': '',
|
||||
# Tashkeel (diacritics) - drop
|
||||
'ً': '', 'ٌ': '', 'ٍ': '', 'َ': '', 'ُ': '', 'ِ': '', 'ّ': '', 'ْ': '',
|
||||
# Zero-width / direction marks
|
||||
'': ' ', '': ' ',
|
||||
'': '', '': '',
|
||||
# Arabic-Indic / Persian digits -> ASCII
|
||||
'۰': '0', '۱': '1', '۲': '2', '۳': '3', '۴': '4',
|
||||
'۵': '5', '۶': '6', '۷': '7', '۸': '8', '۹': '9',
|
||||
'٠': '0', '١': '1', '٢': '2', '٣': '3', '٤': '4',
|
||||
'٥': '5', '٦': '6', '٧': '7', '٨': '8', '٩': '9',
|
||||
})
|
||||
|
||||
|
||||
def _normalize_search_text(text):
|
||||
"""Normalize a search string to handle Persian/Arabic variants, ZWNJ, and case."""
|
||||
if not text:
|
||||
return ''
|
||||
return re.sub(r'\s+', ' ', text.translate(_PERSIAN_CHAR_MAP)).strip().lower()
|
||||
|
||||
|
||||
# SQL-side equivalent of _PERSIAN_CHAR_MAP for PostgreSQL translate().
|
||||
# Each char at position i in FROM is replaced by char at position i in TO;
|
||||
# chars past len(TO) are deleted entirely. This must mirror the Python map so
|
||||
# stored values and query strings normalize to the same form.
|
||||
_SQL_NORM_FROM = (
|
||||
'يك' # Arabic ya/kaf -> Persian
|
||||
'ﻱﻲﻳﻴ' # Arabic ya presentation forms
|
||||
'ﻙﻚﻛﻜ' # Arabic kaf presentation forms
|
||||
'آأإٱ' # alef variants
|
||||
'ؤ' # waw with hamza
|
||||
'ئ' # ya with hamza
|
||||
'ةۀ' # ta marbuta / he with hamza
|
||||
'ﻩﻪﻫﻬ' # he presentation forms
|
||||
'' # ZWNJ, ZWJ -> space
|
||||
'۰۱۲۳۴۵۶۷۸۹' # Persian digits
|
||||
'٠١٢٣٤٥٦٧٨٩' # Arabic-Indic digits
|
||||
# Deletions (no matching char in TO):
|
||||
'ـ' # tatweel
|
||||
'' # LRM, RLM
|
||||
'ًٌٍَُِّْ' # tashkeel
|
||||
)
|
||||
_SQL_NORM_TO = (
|
||||
'یک'
|
||||
'یییی'
|
||||
'کککک'
|
||||
'اااا'
|
||||
'و'
|
||||
'ی'
|
||||
'هه'
|
||||
'هههه'
|
||||
' '
|
||||
'0123456789'
|
||||
'0123456789'
|
||||
)
|
||||
|
||||
|
||||
def NormalizePersian(expression):
|
||||
"""SQL expression that calls the ``normalize_persian(text)`` Postgres function.
|
||||
|
||||
The function (defined in migration 0076) computes ``lower(translate(t, FROM, TO))``
|
||||
and is marked IMMUTABLE so GIN trigram indexes on ``normalize_persian(name)``
|
||||
etc. can be matched by the planner. Calling the function (instead of inlining
|
||||
translate/lower) is what lets queries use those indexes — otherwise every
|
||||
search is a full sequential scan.
|
||||
"""
|
||||
return Func(expression, function='normalize_persian', output_field=CharField())
|
||||
|
||||
|
||||
def _apply_product_search(queryset, search_query):
|
||||
"""Filter and rank a Product queryset by a (possibly Persian) search query.
|
||||
|
||||
Returns (queryset, normalized_query). The queryset is annotated with
|
||||
``similarity`` so callers can ``order_by('-similarity', ...)``. When no
|
||||
product strictly matches, falls back to a looser similarity-based filter
|
||||
so the user sees suggestions instead of an empty page.
|
||||
"""
|
||||
normalized_query = _normalize_search_text(search_query) if search_query else ''
|
||||
if not normalized_query:
|
||||
return queryset, ''
|
||||
|
||||
tokens = [t for t in normalized_query.split(' ') if len(t) >= 2]
|
||||
|
||||
annotated = queryset.annotate(
|
||||
norm_name=NormalizePersian('name'),
|
||||
norm_keywords=NormalizePersian(Coalesce('meta_keywords', Value(''))),
|
||||
norm_category=NormalizePersian(Coalesce('category__name', Value(''))),
|
||||
norm_desc=NormalizePersian(Coalesce('description', Value(''))),
|
||||
).annotate(
|
||||
name_sim=TrigramSimilarity(F('norm_name'), normalized_query),
|
||||
keywords_sim=TrigramSimilarity(F('norm_keywords'), normalized_query),
|
||||
category_sim=TrigramSimilarity(F('norm_category'), normalized_query),
|
||||
desc_sim=TrigramSimilarity(F('norm_desc'), normalized_query),
|
||||
).annotate(
|
||||
# Word-boundary aware bonuses. The space-padded variants are what make
|
||||
# "چای" rank above "چایساز" — the former matches "چای " (word boundary)
|
||||
# while the latter only matches the glued prefix.
|
||||
#
|
||||
# Uses case-sensitive lookups (__contains, not __icontains) because both
|
||||
# sides are already lowercased: __icontains would wrap the expression in
|
||||
# UPPER(...) and break the GIN trigram index match.
|
||||
match_bonus=Case(
|
||||
When(norm_name__exact=normalized_query, then=Value(10.0)),
|
||||
When(norm_name__startswith=normalized_query + ' ', then=Value(6.0)),
|
||||
When(norm_name__startswith=normalized_query, then=Value(3.5)),
|
||||
When(norm_name__contains=' ' + normalized_query + ' ', then=Value(3.0)),
|
||||
When(norm_name__contains=' ' + normalized_query, then=Value(2.5)),
|
||||
When(norm_name__contains=normalized_query + ' ', then=Value(2.5)),
|
||||
When(norm_name__contains=normalized_query, then=Value(1.5)),
|
||||
default=Value(0.0),
|
||||
output_field=FloatField(),
|
||||
)
|
||||
).annotate(
|
||||
similarity=(
|
||||
F('match_bonus')
|
||||
+ F('name_sim') * Value(2.0)
|
||||
+ F('keywords_sim') * Value(0.8)
|
||||
+ F('category_sim') * Value(0.4)
|
||||
+ F('desc_sim') * Value(0.15)
|
||||
)
|
||||
)
|
||||
|
||||
if tokens:
|
||||
# Token AND filter. Limited to fields we have GIN trigram indexes for
|
||||
# (name, keywords, category.name in migration 0076) — including
|
||||
# description or slug here would force a sequential scan on the OR
|
||||
# branch and undo the index speedup. Description still contributes via
|
||||
# ``desc_sim`` to ranking on the already-narrowed result set.
|
||||
token_filter = Q()
|
||||
for token in tokens:
|
||||
token_filter &= (
|
||||
Q(norm_name__contains=token)
|
||||
| Q(norm_keywords__contains=token)
|
||||
| Q(norm_category__contains=token)
|
||||
)
|
||||
strict_filter = (
|
||||
token_filter
|
||||
| Q(name_sim__gte=0.45)
|
||||
| Q(keywords_sim__gte=0.5)
|
||||
)
|
||||
else:
|
||||
strict_filter = Q(name_sim__gte=0.4) | Q(keywords_sim__gte=0.4)
|
||||
|
||||
strict_products = annotated.filter(strict_filter).distinct()
|
||||
if strict_products.exists():
|
||||
return strict_products, normalized_query
|
||||
|
||||
# No strict matches — relax thresholds so the user gets "similar"
|
||||
# suggestions instead of an empty result page.
|
||||
loose_filter = (
|
||||
Q(name_sim__gte=0.18)
|
||||
| Q(keywords_sim__gte=0.22)
|
||||
| Q(category_sim__gte=0.3)
|
||||
| Q(match_bonus__gt=0)
|
||||
)
|
||||
return annotated.filter(loose_filter).distinct(), normalized_query
|
||||
# class APIView(APIView):
|
||||
# def __init__(self, *args, **kwargs):
|
||||
# super().__init__(*args, **kwargs)
|
||||
@@ -324,18 +498,9 @@ class AllProductsView(APIView):
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Search
|
||||
# Search (Persian-aware, with typo tolerance + similar-results fallback)
|
||||
search_query = request.query_params.get('search')
|
||||
if search_query:
|
||||
products = products.annotate(
|
||||
similarity=(
|
||||
TrigramSimilarity('name', search_query) +
|
||||
TrigramSimilarity(
|
||||
Coalesce('description', Value('')),
|
||||
search_query
|
||||
)
|
||||
)
|
||||
).filter(similarity__gt=0.1)
|
||||
products, normalized_query = _apply_product_search(products, search_query)
|
||||
|
||||
# Price annotation (IMPORTANT for sorting)
|
||||
products = products.annotate(
|
||||
@@ -376,8 +541,10 @@ class AllProductsView(APIView):
|
||||
|
||||
elif sort_by in ['price', '-price']:
|
||||
products = products.order_by('min_price' if sort_by == 'price' else '-min_price')
|
||||
elif search_query:
|
||||
products = products.order_by('-similarity', 'name')
|
||||
elif normalized_query:
|
||||
# Tie-break on shorter name: ensures "چای" outranks "چای ساز"
|
||||
# when their bonus-adjusted similarities are close.
|
||||
products = products.order_by('-similarity', Length('norm_name'), 'name')
|
||||
else:
|
||||
products = products.order_by('name')
|
||||
|
||||
@@ -522,11 +689,9 @@ class ShowCaseProductsView(APIView):
|
||||
if has_discount:
|
||||
products = products.filter(variants__discount__gt=0).distinct()
|
||||
|
||||
# Search filter
|
||||
# Search filter (Persian-aware, with typo tolerance + similar-results fallback)
|
||||
search_query = request.query_params.get('search', None)
|
||||
if search_query:
|
||||
products = products.filter(Q(name__icontains=search_query) | Q(
|
||||
description__icontains=search_query))
|
||||
products, normalized_query = _apply_product_search(products, search_query)
|
||||
|
||||
# Price filters
|
||||
price_gte = request.query_params.get('price_gte', None)
|
||||
@@ -543,6 +708,8 @@ class ShowCaseProductsView(APIView):
|
||||
sort_by = request.query_params.get('sort', None)
|
||||
if sort_by in ['name', '-name', 'created_at', '-created_at']:
|
||||
products = products.order_by(sort_by)
|
||||
elif normalized_query:
|
||||
products = products.order_by('-similarity', Length('norm_name'), 'name')
|
||||
else:
|
||||
products = products.order_by('name')
|
||||
|
||||
|
||||
+5
-4
@@ -17,8 +17,7 @@ services:
|
||||
|
||||
django:
|
||||
container_name: shop_backend
|
||||
build:
|
||||
context: ./backend
|
||||
image: fix_update_bank:latest
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
@@ -108,8 +107,10 @@ services:
|
||||
redis:
|
||||
container_name: hshop_redis
|
||||
image: redis:alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
command: >
|
||||
redis-server
|
||||
--maxmemory 512mb
|
||||
--maxmemory-policy volatile-lru
|
||||
networks:
|
||||
- default
|
||||
restart: always
|
||||
|
||||
@@ -113,11 +113,15 @@ const onSwiper = (swiper: SwiperClass) => {
|
||||
</Swiper>
|
||||
</div>
|
||||
<template #fallback>
|
||||
<div class="w-full grid grid-cols-3 sm:grid-cols-4 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 class="bg-neutral-100 !size-full !rounded-2xl !aspect-square" />
|
||||
<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>
|
||||
|
||||
@@ -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 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"
|
||||
|
||||
@@ -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)`,
|
||||
|
||||
@@ -102,7 +102,7 @@ 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-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>
|
||||
|
||||
<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">
|
||||
<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]) => {
|
||||
@@ -77,31 +68,32 @@ watch(
|
||||
</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="product!.rating" />
|
||||
<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 }}
|
||||
@@ -205,12 +200,14 @@ watch(
|
||||
|
||||
<Rating
|
||||
:rate="product!.rating"
|
||||
:have-rate="product!.rating !== 0"
|
||||
class="sm:hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Rating
|
||||
:rate="product!.rating"
|
||||
:have-rate="product!.rating !== 0"
|
||||
class="max-sm:hidden"
|
||||
/>
|
||||
</div>
|
||||
@@ -338,8 +335,6 @@ watch(
|
||||
</div>
|
||||
|
||||
<InfoCard />
|
||||
|
||||
<Share />
|
||||
</div>
|
||||
|
||||
<ProductDescription
|
||||
|
||||
@@ -73,6 +73,6 @@ onMounted(() => {
|
||||
</div>
|
||||
<ProductComments :product="product!" />
|
||||
|
||||
<ChatButton :showChatButton="showChatButton" />
|
||||
<!-- <ChatButton :showChatButton="showChatButton" /> -->
|
||||
</div>
|
||||
</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