21 Commits

Author SHA1 Message Date
marzban-dev c1d08496c9 Add skeleton for categories section 2026-06-03 20:07:19 +03:30
Parsa Nazer 0567593edd fix guarantee default value 2026-06-02 09:15:01 +03:30
Parsa Nazer 6f2037309c ypdate search 2026-05-30 09:06:25 +03:30
Parsa Nazer e6965fe3b8 update frontend 2026-05-28 10:35:35 +03:30
Parsa Nazer a89376e4c4 update redis run configs 2026-05-28 10:35:13 +03:30
Parsa Nazer c07d4b802b add update torb web hock to admin 2026-05-28 10:34:34 +03:30
Parsa Nazer cc8ced184d update torob web hok 2026-05-28 10:34:21 +03:30
Parsa Nazer 335a0c2f7e guarantie migrations 2026-05-28 10:33:48 +03:30
Parsa Nazer cc98dc4ccf add guarantee filed to product varient and add timeout for nobitex api 2026-05-28 10:33:39 +03:30
Parsa Nazer 2b1c2b72c1 update torob api 2026-05-28 10:33:07 +03:30
marzban-dev e5eaf80199 Add share icon 2026-05-26 19:27:03 +03:30
marzban-dev 74a61844a0 Update style 2026-05-26 19:26:25 +03:30
marzban-dev ee7b7eebad Add toast to save button if no token found 2026-05-26 19:25:54 +03:30
marzban-dev 642b41ffaf Updated 2026-05-26 19:24:50 +03:30
marzban-dev f0b03e27b3 Add skeleton for swiper 2026-05-26 19:21:00 +03:30
marzban-dev ccf18fb768 Add hasRating prop for showing ( no comment ) text 2026-05-26 19:20:42 +03:30
marzban-dev b09995920c Update share component 2026-05-26 19:19:59 +03:30
marzban-dev 7ccb2f445e Fix padding 2026-05-26 19:19:38 +03:30
marzban-dev 048f5435ff Extract save button logic 2026-05-26 11:14:52 +03:30
marzban-dev 886a3ee541 Add products page link before category name 2026-05-26 10:57:27 +03:30
marzban-dev ee90708751 Comment chat button for now 2026-05-26 10:57:12 +03:30
21 changed files with 776 additions and 242 deletions
+53 -2
View File
@@ -414,7 +414,7 @@ class ProductModelAdmin(ProductAdminPermission, ModelAdmin, ImportExportModelAdm
# compressed_fields = True
warn_unsaved_form = True
# list_per_page = 2
actions_list = ['redirect_to_learn', 'update_products_price']
actions_list = ['redirect_to_learn', 'update_products_price', 'resync_all_torob']
list_display = ['display_image', 'shop__shop_name', 'view', 'rating', 'category', 'created_at' ,'show_in_website', ]
fieldsets = (
('فیلد های اصلی', {'fields': ('name', 'description', 'category', 'image', 'related_products','show_in_trends', 'show_in_most_viewed', 'show_in_lot_of_discount', 'show_in_top_seller', 'shop', 'show_in_bot', 'bot_banner'), "classes": ["tab"],}),
@@ -497,6 +497,57 @@ class ProductModelAdmin(ProductAdminPermission, ModelAdmin, ImportExportModelAdm
messages.success(request, f"قیمت {ProductVariant.objects.all().count()} تنوع محصول اپدیت شد")
return redirect("admin:product_productmodel_changelist")
@action(description="ارسال مجدد همه محصولات به ترب")
def resync_all_torob(self, request):
from django.conf import settings
from product.tasks import send_torob_product_webhook
if not getattr(settings, "TOROB_PRODUCT_WEBHOOK_TOKEN", None):
messages.error(request, "توکن وبهوک ترب در تنظیمات وجود ندارد")
return redirect("admin:product_productmodel_changelist")
product_ids = list(
ProductModel.objects.exclude(slug__isnull=True).exclude(slug="").values_list("id", flat=True)
)
if not product_ids:
messages.warning(request, "محصولی برای ارسال یافت نشد")
return redirect("admin:product_productmodel_changelist")
chunk_size = 50
queued = 0
for start in range(0, len(product_ids), chunk_size):
send_torob_product_webhook.delay(product_ids[start:start + chunk_size])
queued += 1
messages.success(
request,
f"{len(product_ids)} محصول در {queued} بسته برای ارسال به ترب در صف قرار گرفت",
)
return redirect("admin:product_productmodel_changelist")
def resync_selected_torob(self, request, queryset):
from django.conf import settings
from product.tasks import send_torob_product_webhook
if not getattr(settings, "TOROB_PRODUCT_WEBHOOK_TOKEN", None):
messages.error(request, "توکن وبهوک ترب در تنظیمات وجود ندارد")
return
product_ids = list(
queryset.exclude(slug__isnull=True).exclude(slug="").values_list("id", flat=True)
)
if not product_ids:
messages.warning(request, "محصول معتبری انتخاب نشد")
return
chunk_size = 50
for start in range(0, len(product_ids), chunk_size):
send_torob_product_webhook.delay(product_ids[start:start + chunk_size])
messages.success(request, f"{len(product_ids)} محصول برای ارسال به ترب در صف قرار گرفت")
resync_selected_torob.short_description = "ارسال محصولات انتخاب شده به ترب"
def bulk_update_subcategory_action(self, request, queryset):
"""اکشن برای تغییر دسته‌بندی چند محصول همزمان"""
@@ -535,7 +586,7 @@ class ProductModelAdmin(ProductAdminPermission, ModelAdmin, ImportExportModelAdm
)
bulk_update_subcategory_action.short_description = "تغییر دسته‌بندی محصولات انتخاب شده"
actions = ['bulk_update_subcategory_action']
actions = ['bulk_update_subcategory_action', 'resync_selected_torob']
@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("product", "0074_productmodel_updated_at_productvariant_updated_at"),
]
operations = [
migrations.AddField(
model_name="productvariant",
name="guarantee",
field=models.CharField(
default="گرانتی اصالت و سلامت کالا",
help_text="این مقدار به ترب ارسال می‌شود تا محصول به‌عنوان نو طبقه‌بندی شود",
max_length=200,
verbose_name="گارانتی",
),
),
]
@@ -0,0 +1,102 @@
"""
Add an IMMUTABLE ``normalize_persian(text)`` SQL function and GIN trigram
indexes that match it. Lets the search view filter and rank without doing a
per-row ``translate()`` on a sequential scan — drops query time from seconds
to tens of milliseconds.
The FROM/TO strings here MUST stay aligned with ``_SQL_NORM_FROM`` /
``_SQL_NORM_TO`` in ``product/views.py``. If you change one, change the other
and add a follow-up migration that recreates the function + indexes (Postgres
matches expression indexes by exact SQL form, so a stale function would
silently bypass the indexes).
"""
from django.db import migrations
# Mirror of product.views._SQL_NORM_FROM / _SQL_NORM_TO.
_SQL_NORM_FROM = (
'يك' # Arabic ya/kaf -> Persian
'ﻱﻲﻳﻴ' # Arabic ya presentation forms
'ﻙﻚﻛﻜ' # Arabic kaf presentation forms
'آأإٱ' # alef variants
'ؤ' # waw with hamza
'ئ' # ya with hamza
'ةۀ' # ta marbuta / he with hamza
'ﻩﻪﻫﻬ' # he presentation forms
'' # ZWNJ, ZWJ -> space
'۰۱۲۳۴۵۶۷۸۹' # Persian digits
'٠١٢٣٤٥٦٧٨٩' # Arabic-Indic digits
# Deletions (no matching char in TO):
'ـ' # tatweel
'' # LRM, RLM
'ًٌٍَُِّْ' # tashkeel
)
_SQL_NORM_TO = (
'یک'
'یییی'
'کککک'
'اااا'
'و'
'ی'
'هه'
'هههه'
' '
'0123456789'
'0123456789'
)
def _pg_str(s):
"""Quote a Python string as a PostgreSQL string literal."""
return "'" + s.replace("'", "''") + "'"
CREATE_FUNCTION_SQL = f"""
CREATE OR REPLACE FUNCTION normalize_persian(t text) RETURNS text AS $$
SELECT lower(translate(t, {_pg_str(_SQL_NORM_FROM)}, {_pg_str(_SQL_NORM_TO)}));
$$ LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE;
"""
DROP_FUNCTION_SQL = "DROP FUNCTION IF EXISTS normalize_persian(text);"
class Migration(migrations.Migration):
dependencies = [
("product", "0075_productvariant_guarantee"),
]
operations = [
migrations.RunSQL(
sql=CREATE_FUNCTION_SQL,
reverse_sql=DROP_FUNCTION_SQL,
),
# GIN trigram indexes on the normalized expression. PostgreSQL matches
# queries that use exactly ``normalize_persian(<col>)`` against these
# indexes, so the views.py wrapper must call the SQL function (not
# inline translate/lower) for the index to be used.
migrations.RunSQL(
sql=(
"CREATE INDEX IF NOT EXISTS product_norm_name_trgm_idx "
"ON product_productmodel "
"USING gin (normalize_persian(name) gin_trgm_ops);"
),
reverse_sql="DROP INDEX IF EXISTS product_norm_name_trgm_idx;",
),
migrations.RunSQL(
sql=(
"CREATE INDEX IF NOT EXISTS product_norm_keywords_trgm_idx "
"ON product_productmodel "
"USING gin (normalize_persian(meta_keywords) gin_trgm_ops);"
),
reverse_sql="DROP INDEX IF EXISTS product_norm_keywords_trgm_idx;",
),
migrations.RunSQL(
sql=(
"CREATE INDEX IF NOT EXISTS subcategory_norm_name_trgm_idx "
"ON product_subcategorymodel "
"USING gin (normalize_persian(name) gin_trgm_ops);"
),
reverse_sql="DROP INDEX IF EXISTS subcategory_norm_name_trgm_idx;",
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2 on 2026-06-02 05:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('product', '0076_normalize_persian_search'),
]
operations = [
migrations.AlterField(
model_name='productvariant',
name='guarantee',
field=models.CharField(default='گارانتی اصالت و سلامت کالا', help_text='این مقدار به ترب ارسال می\u200cشود تا محصول به\u200cعنوان نو طبقه\u200cبندی شود', max_length=200, verbose_name='گارانتی'),
),
]
+5 -1
View File
@@ -147,7 +147,7 @@ class DollorModel(models.Model):
def get_usd_price(self):
try:
api_usd = "https://api.nobitex.ir/v2/orderbook/USDTIRT"
response = requests.get(api_usd)
response = requests.get(api_usd, timeout=5)
data = response.json()
price = int(data["lastTradePrice"])
price_in_usd = price / 10.0
@@ -403,6 +403,10 @@ class ProductVariant(DirtyFieldsMixin, models.Model):
discount = models.SmallIntegerField(default=0, verbose_name='درصد تخفیف', help_text='این درصد از قیمت نهایی محصول کسر میگردد')
color = models.CharField(
verbose_name='رنگ', max_length=7, blank=True, null=True)
guarantee = models.CharField(
max_length=200, default='گارانتی اصالت و سلامت کالا',
verbose_name='گارانتی',
help_text='این مقدار به ترب ارسال می‌شود تا محصول به‌عنوان نو طبقه‌بندی شود')
images = models.ManyToManyField(ProductImageModel, verbose_name='عکس ها')
video = models.FileField(upload_to='product_videos/',
blank=True, null=True, verbose_name='ویدیو')
+13 -10
View File
@@ -17,13 +17,16 @@ TOROB_WEBHOOK_MIN_INTERVAL_SECONDS = 3.1
TOROB_WEBHOOK_MAX_RETRIES = 3
def _shop_product_url(product: ProductModel) -> str:
def _shop_product_url(product: ProductModel, variant: ProductVariant | None = None) -> str:
domain = getattr(settings, "DOMAIN", None) or getattr(settings, "API_DOMAIN", None) or ""
if domain.startswith("http://") or domain.startswith("https://"):
base = domain.rstrip("/")
else:
base = f"https://{domain}".rstrip("/") if domain else ""
return f"{base}/product/{product.slug}/"
url = f"{base}/product/{product.slug}/"
if variant is not None:
url = f"{url}?variant={variant.pk}"
return url
def _variant_page_unique(product: ProductModel, variant: ProductVariant) -> str:
@@ -124,18 +127,18 @@ def send_torob_product_webhook(product_ids):
if not product.slug:
continue
page_url = _shop_product_url(product)
parsed = urlparse(page_url)
if not (parsed.scheme in {"http", "https"} and parsed.netloc):
logger.warning("Skipping product %s due to invalid page_url: %s", product.pk, page_url)
continue
hosts.add(parsed.netloc.lower())
variants = list(product.variants.all())
if not variants:
continue
for variant in variants:
variant_page_url = _shop_product_url(product, variant)
parsed = urlparse(variant_page_url)
if not (parsed.scheme in {"http", "https"} and parsed.netloc):
logger.warning("Skipping variant %s due to invalid page_url: %s", variant.pk, variant_page_url)
continue
hosts.add(parsed.netloc.lower())
# Validate variant has images before sending to Torob
# Per spec: image_links is required, so skip variants without images
images = list(variant.images.all())
@@ -148,7 +151,7 @@ def send_torob_product_webhook(product_ids):
items.append(
{
"page_url": page_url,
"page_url": variant_page_url,
"page_unique": _variant_page_unique(product, variant),
}
)
+45 -12
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import logging
import math
from urllib.parse import urlparse
from urllib.parse import parse_qs, urlparse
import jwt
from django.conf import settings
@@ -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
View File
@@ -1,3 +1,4 @@
import re
from .models import ProductModel
from rest_framework import serializers
from django.core.paginator import Paginator
@@ -6,8 +7,8 @@ from .models import *
from .serializers import *
from rest_framework import status
from rest_framework.response import Response
from django.db.models import Q, Value
from django.db.models.functions import Coalesce
from django.db.models import Q, Value, Case, When, FloatField, F, CharField, Func
from django.db.models.functions import Coalesce, Length
from django.contrib.postgres.search import TrigramSimilarity
from django.shortcuts import get_object_or_404
from rest_framework.permissions import IsAuthenticatedOrReadOnly
@@ -21,6 +22,179 @@ from home.models import ShowCaseSlider
from home.serializers import ShowCaseSliderSerialzier
from order.models import Cart, CartItem
from django.db.models import Min, Max, Value
_PERSIAN_CHAR_MAP = str.maketrans({
# Arabic letters -> Persian equivalents
'ي': 'ی', 'ك': 'ک',
# Arabic ya/kaf presentation forms -> Persian
'': 'ی', '': 'ی', '': 'ی', '': 'ی',
'': 'ک', '': 'ک', '': 'ک', '': 'ک',
# Alef variants -> bare alef (so "ایفون" matches "آیفون")
'آ': 'ا', 'أ': 'ا', 'إ': 'ا', 'ٱ': 'ا',
# Hamza on waw/ya -> bare letter
'ؤ': 'و',
'ئ': 'ی',
# Ta marbuta / he variants -> he
'ة': 'ه', 'ۀ': 'ه',
'': 'ه', '': 'ه', '': 'ه', '': 'ه',
# Tatweel - drop
'ـ': '',
# Tashkeel (diacritics) - drop
'ً': '', 'ٌ': '', 'ٍ': '', 'َ': '', 'ُ': '', 'ِ': '', 'ّ': '', 'ْ': '',
# Zero-width / direction marks
'': ' ', '': ' ',
'': '', '': '',
# Arabic-Indic / Persian digits -> ASCII
'۰': '0', '۱': '1', '۲': '2', '۳': '3', '۴': '4',
'۵': '5', '۶': '6', '۷': '7', '۸': '8', '۹': '9',
'٠': '0', '١': '1', '٢': '2', '٣': '3', '٤': '4',
'٥': '5', '٦': '6', '٧': '7', '٨': '8', '٩': '9',
})
def _normalize_search_text(text):
"""Normalize a search string to handle Persian/Arabic variants, ZWNJ, and case."""
if not text:
return ''
return re.sub(r'\s+', ' ', text.translate(_PERSIAN_CHAR_MAP)).strip().lower()
# SQL-side equivalent of _PERSIAN_CHAR_MAP for PostgreSQL translate().
# Each char at position i in FROM is replaced by char at position i in TO;
# chars past len(TO) are deleted entirely. This must mirror the Python map so
# stored values and query strings normalize to the same form.
_SQL_NORM_FROM = (
'يك' # Arabic ya/kaf -> Persian
'ﻱﻲﻳﻴ' # Arabic ya presentation forms
'ﻙﻚﻛﻜ' # Arabic kaf presentation forms
'آأإٱ' # alef variants
'ؤ' # waw with hamza
'ئ' # ya with hamza
'ةۀ' # ta marbuta / he with hamza
'ﻩﻪﻫﻬ' # he presentation forms
'' # ZWNJ, ZWJ -> space
'۰۱۲۳۴۵۶۷۸۹' # Persian digits
'٠١٢٣٤٥٦٧٨٩' # Arabic-Indic digits
# Deletions (no matching char in TO):
'ـ' # tatweel
'' # LRM, RLM
'ًٌٍَُِّْ' # tashkeel
)
_SQL_NORM_TO = (
'یک'
'یییی'
'کککک'
'اااا'
'و'
'ی'
'هه'
'هههه'
' '
'0123456789'
'0123456789'
)
def NormalizePersian(expression):
"""SQL expression that calls the ``normalize_persian(text)`` Postgres function.
The function (defined in migration 0076) computes ``lower(translate(t, FROM, TO))``
and is marked IMMUTABLE so GIN trigram indexes on ``normalize_persian(name)``
etc. can be matched by the planner. Calling the function (instead of inlining
translate/lower) is what lets queries use those indexes — otherwise every
search is a full sequential scan.
"""
return Func(expression, function='normalize_persian', output_field=CharField())
def _apply_product_search(queryset, search_query):
"""Filter and rank a Product queryset by a (possibly Persian) search query.
Returns (queryset, normalized_query). The queryset is annotated with
``similarity`` so callers can ``order_by('-similarity', ...)``. When no
product strictly matches, falls back to a looser similarity-based filter
so the user sees suggestions instead of an empty page.
"""
normalized_query = _normalize_search_text(search_query) if search_query else ''
if not normalized_query:
return queryset, ''
tokens = [t for t in normalized_query.split(' ') if len(t) >= 2]
annotated = queryset.annotate(
norm_name=NormalizePersian('name'),
norm_keywords=NormalizePersian(Coalesce('meta_keywords', Value(''))),
norm_category=NormalizePersian(Coalesce('category__name', Value(''))),
norm_desc=NormalizePersian(Coalesce('description', Value(''))),
).annotate(
name_sim=TrigramSimilarity(F('norm_name'), normalized_query),
keywords_sim=TrigramSimilarity(F('norm_keywords'), normalized_query),
category_sim=TrigramSimilarity(F('norm_category'), normalized_query),
desc_sim=TrigramSimilarity(F('norm_desc'), normalized_query),
).annotate(
# Word-boundary aware bonuses. The space-padded variants are what make
# "چای" rank above "چایساز" — the former matches "چای " (word boundary)
# while the latter only matches the glued prefix.
#
# Uses case-sensitive lookups (__contains, not __icontains) because both
# sides are already lowercased: __icontains would wrap the expression in
# UPPER(...) and break the GIN trigram index match.
match_bonus=Case(
When(norm_name__exact=normalized_query, then=Value(10.0)),
When(norm_name__startswith=normalized_query + ' ', then=Value(6.0)),
When(norm_name__startswith=normalized_query, then=Value(3.5)),
When(norm_name__contains=' ' + normalized_query + ' ', then=Value(3.0)),
When(norm_name__contains=' ' + normalized_query, then=Value(2.5)),
When(norm_name__contains=normalized_query + ' ', then=Value(2.5)),
When(norm_name__contains=normalized_query, then=Value(1.5)),
default=Value(0.0),
output_field=FloatField(),
)
).annotate(
similarity=(
F('match_bonus')
+ F('name_sim') * Value(2.0)
+ F('keywords_sim') * Value(0.8)
+ F('category_sim') * Value(0.4)
+ F('desc_sim') * Value(0.15)
)
)
if tokens:
# Token AND filter. Limited to fields we have GIN trigram indexes for
# (name, keywords, category.name in migration 0076) — including
# description or slug here would force a sequential scan on the OR
# branch and undo the index speedup. Description still contributes via
# ``desc_sim`` to ranking on the already-narrowed result set.
token_filter = Q()
for token in tokens:
token_filter &= (
Q(norm_name__contains=token)
| Q(norm_keywords__contains=token)
| Q(norm_category__contains=token)
)
strict_filter = (
token_filter
| Q(name_sim__gte=0.45)
| Q(keywords_sim__gte=0.5)
)
else:
strict_filter = Q(name_sim__gte=0.4) | Q(keywords_sim__gte=0.4)
strict_products = annotated.filter(strict_filter).distinct()
if strict_products.exists():
return strict_products, normalized_query
# No strict matches — relax thresholds so the user gets "similar"
# suggestions instead of an empty result page.
loose_filter = (
Q(name_sim__gte=0.18)
| Q(keywords_sim__gte=0.22)
| Q(category_sim__gte=0.3)
| Q(match_bonus__gt=0)
)
return annotated.filter(loose_filter).distinct(), normalized_query
# class APIView(APIView):
# def __init__(self, *args, **kwargs):
# super().__init__(*args, **kwargs)
@@ -324,18 +498,9 @@ class AllProductsView(APIView):
status=status.HTTP_400_BAD_REQUEST
)
# Search
# Search (Persian-aware, with typo tolerance + similar-results fallback)
search_query = request.query_params.get('search')
if search_query:
products = products.annotate(
similarity=(
TrigramSimilarity('name', search_query) +
TrigramSimilarity(
Coalesce('description', Value('')),
search_query
)
)
).filter(similarity__gt=0.1)
products, normalized_query = _apply_product_search(products, search_query)
# Price annotation (IMPORTANT for sorting)
products = products.annotate(
@@ -376,8 +541,10 @@ class AllProductsView(APIView):
elif sort_by in ['price', '-price']:
products = products.order_by('min_price' if sort_by == 'price' else '-min_price')
elif search_query:
products = products.order_by('-similarity', 'name')
elif normalized_query:
# Tie-break on shorter name: ensures "چای" outranks "چای ساز"
# when their bonus-adjusted similarities are close.
products = products.order_by('-similarity', Length('norm_name'), 'name')
else:
products = products.order_by('name')
@@ -522,11 +689,9 @@ class ShowCaseProductsView(APIView):
if has_discount:
products = products.filter(variants__discount__gt=0).distinct()
# Search filter
# Search filter (Persian-aware, with typo tolerance + similar-results fallback)
search_query = request.query_params.get('search', None)
if search_query:
products = products.filter(Q(name__icontains=search_query) | Q(
description__icontains=search_query))
products, normalized_query = _apply_product_search(products, search_query)
# Price filters
price_gte = request.query_params.get('price_gte', None)
@@ -543,6 +708,8 @@ class ShowCaseProductsView(APIView):
sort_by = request.query_params.get('sort', None)
if sort_by in ['name', '-name', 'created_at', '-created_at']:
products = products.order_by(sort_by)
elif normalized_query:
products = products.order_by('-similarity', Length('norm_name'), 'name')
else:
products = products.order_by('name')
+5 -4
View File
@@ -17,8 +17,7 @@ services:
django:
container_name: shop_backend
build:
context: ./backend
image: fix_update_bank:latest
ports:
- "8000:8000"
depends_on:
@@ -108,8 +107,10 @@ services:
redis:
container_name: hshop_redis
image: redis:alpine
ports:
- "6379:6379"
command: >
redis-server
--maxmemory 512mb
--maxmemory-policy volatile-lru
networks:
- default
restart: always
@@ -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"
+23 -3
View File
@@ -42,8 +42,12 @@ const onSlideChange = (swiper: SwiperClass) => {
<span class="text-white typo-h-6 md:typo-h-5 lg:typo-h-4 min-[2000px]:typo-h-2"> دسته بندی ها </span>
</div>
<div class="w-full mt-44 lg:mt-64 relative">
<div
class="w-full relative"
:class="swiper_instance ? 'mt-44 lg:mt-64' : 'mt-24'"
>
<NuxtImg
v-if="swiper_instance"
class="aspect-square w-[210px] sm:w-[240px] md:w-[300px] lg:w-[350px] 2xl:w-[420px] translate-y-[-136px] sm:translate-y-[-156px] md:translate-y-[-195px] lg:translate-y-[-228px] 2xl:translate-y-[-273px] absolute left-1/2 -translate-x-1/2 z-10"
:style="{
filter: 'drop-shadow(0px 0px 20px rgba(0, 0, 0, 0.4))',
@@ -52,6 +56,8 @@ const onSlideChange = (swiper: SwiperClass) => {
fetch-priority="low"
src="/img/heymlz/heymlz-category-seat.gif"
/>
<ClientOnly>
<Swiper
:loop="true"
:centered-slides="true"
@@ -124,8 +130,22 @@ const onSlideChange = (swiper: SwiperClass) => {
</SwiperSlide>
</Swiper>
<template #fallback>
<div
v-if="!swiper_instance?.isBeginning"
class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 min-[3000px]:grid-cols-6 gap-12 lg:gap-20 px-12 lg:px-20"
>
<div class="w-full aspect-square bg-neutral-800/75 rounded-150"></div>
<div class="w-full aspect-square bg-neutral-800/75 rounded-150"></div>
<div class="w-full aspect-square bg-neutral-800/75 rounded-150 max-md:hidden"></div>
<div class="w-full aspect-square bg-neutral-800/75 rounded-150 max-xl:hidden"></div>
<div class="w-full aspect-square bg-neutral-800/75 rounded-150 max-[3000px]:hidden"></div>
<div class="w-full aspect-square bg-neutral-800/75 rounded-150 max-[3000px]:hidden"></div>
</div>
</template>
</ClientOnly>
<div
v-if="swiper_instance && !swiper_instance?.isBeginning"
@click="swiper_instance?.slidePrev()"
:style="{
right: `calc(50% - ${slideWidth / 2}px - 20px)`,
@@ -139,7 +159,7 @@ const onSlideChange = (swiper: SwiperClass) => {
</div>
<div
v-if="!swiper_instance?.isEnd"
v-if="swiper_instance && !swiper_instance?.isEnd"
@click="swiper_instance?.slideNext()"
:style="{
left: `calc(50% - ${slideWidth / 2}px - 20px)`,
@@ -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
+1 -1
View File
@@ -73,6 +73,6 @@ onMounted(() => {
</div>
<ProductComments :product="product!" />
<ChatButton :showChatButton="showChatButton" />
<!-- <ChatButton :showChatButton="showChatButton" /> -->
</div>
</template>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 16 16"><!-- Icon from Bootstrap Icons by The Bootstrap Authors - https://github.com/twbs/icons/blob/main/LICENSE.md --><path fill="currentColor" d="M13.5 1a1.5 1.5 0 1 0 0 3a1.5 1.5 0 0 0 0-3M11 2.5a2.5 2.5 0 1 1 .603 1.628l-6.718 3.12a2.5 2.5 0 0 1 0 1.504l6.718 3.12a2.5 2.5 0 1 1-.488.876l-6.718-3.12a2.5 2.5 0 1 1 0-3.256l6.718-3.12A2.5 2.5 0 0 1 11 2.5m-8.5 4a1.5 1.5 0 1 0 0 3a1.5 1.5 0 0 0 0-3m11 5.5a1.5 1.5 0 1 0 0 3a1.5 1.5 0 0 0 0-3"/></svg>

After

Width:  |  Height:  |  Size: 528 B