This commit is contained in:
marzban-dev
2026-05-15 13:43:53 +03:30
30 changed files with 432 additions and 123 deletions
+8 -6
View File
@@ -2,6 +2,9 @@ from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, Permis
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import random import random
import logging
logger = logging.getLogger(__name__)
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.utils import timezone from django.utils import timezone
from rest_framework_simplejwt.token_blacklist.models import BlacklistedToken, OutstandingToken from rest_framework_simplejwt.token_blacklist.models import BlacklistedToken, OutstandingToken
@@ -113,7 +116,7 @@ class User(AbstractBaseUser, PermissionsMixin):
for token in tokens: for token in tokens:
BlacklistedToken.objects.get_or_create(token=token) BlacklistedToken.objects.get_or_create(token=token)
except Exception as e: except Exception as e:
print(f"block list error: {e}") logger.error(f"block list error: {e}")
def __str__(self): def __str__(self):
@@ -200,7 +203,6 @@ class PushSubscription(models.Model):
"icon": 'https://api.heymlz.com' + icon, "icon": 'https://api.heymlz.com' + icon,
"image": 'https://api.heymlz.com' + icon, "image": 'https://api.heymlz.com' + icon,
} }
print(payload)
try: try:
webpush( webpush(
subscription_info={ subscription_info={
@@ -214,7 +216,7 @@ class PushSubscription(models.Model):
} }
) )
except WebPushException as ex: except WebPushException as ex:
print("Failed to send notification:", ex) logger.error(f"Failed to send notification: {ex}")
@classmethod @classmethod
def send_group_notification(cls, user, title, body): def send_group_notification(cls, user, title, body):
@@ -240,7 +242,7 @@ class PushSubscription(models.Model):
} }
) )
except WebPushException as ex: except WebPushException as ex:
print(f"Failed to send notification to {sub.user}:", ex) logger.error(f"Failed to send notification to {sub.user}: {ex}")
@@ -277,10 +279,10 @@ def get_location_from_ip(ip_address):
if data["status"] == "success": if data["status"] == "success":
return data['country'], data['regionName'], data['city'], data.get('zip', 'ناموجود'), data['lat'], data['lon'], data['isp'] return data['country'], data['regionName'], data['city'], data.get('zip', 'ناموجود'), data['lat'], data['lon'], data['isp']
else: else:
print("Error fetching data: ", data["message"]) logger.error(f"Error fetching data: {data['message']}")
return None return None
except Exception as e: except Exception as e:
print(f"An error occurred: {e}") logger.error(f"An error occurred: {e}")
return None return None
class SecurityBreachAttemptModel(models.Model): class SecurityBreachAttemptModel(models.Model):
-1
View File
@@ -7,7 +7,6 @@ urlpatterns = [
path('profile', views.ProfileView.as_view()), path('profile', views.ProfileView.as_view()),
path('verify', views.TokenVerifyView.as_view(), name='jwt-verify'), path('verify', views.TokenVerifyView.as_view(), name='jwt-verify'),
path('send_otp', views.SendOTPView.as_view(), name='send-otp-view'), path('send_otp', views.SendOTPView.as_view(), name='send-otp-view'),
path('yee_token_bedeeee', views.KonGhoshadToken.as_view()),
path('address/create', views.CreateAddressView.as_view(), name='create-address'), path('address/create', views.CreateAddressView.as_view(), name='create-address'),
path('address/edit/<int:pk>', views.EditAddressView.as_view(), name='edit-address'), path('address/edit/<int:pk>', views.EditAddressView.as_view(), name='edit-address'),
path('address/delete/<int:pk>', views.DeleteAddressView.as_view(), name='delete-address'), path('address/delete/<int:pk>', views.DeleteAddressView.as_view(), name='delete-address'),
+3 -3
View File
@@ -71,10 +71,10 @@ class SendOTPView(APIView):
if response['statusCode'] == 200: if response['statusCode'] == 200:
return Response({'detail': f'OTP sent successfully {otp}'}, status=status.HTTP_200_OK) return Response({'detail': f'OTP sent successfully'}, status=status.HTTP_200_OK)
else: else:
print(response) logger.error(f"OTP send failed with response: {response}")
return Response({f'detail': f'مشکلی در ارسال کد رخ داد {otp}'}, status=status.HTTP_200_OK) return Response({f'detail': f'مشکلی در ارسال کد رخ داد '}, status=status.HTTP_200_OK)
# return Response({'detail': response, 'otp_code': otp}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) # return Response({'detail': response, 'otp_code': otp}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
except User.DoesNotExist: except User.DoesNotExist:
+4 -1
View File
@@ -1,5 +1,8 @@
from django.db import models from django.db import models
from account.models import User from account.models import User
import logging
logger = logging.getLogger(__name__)
from product.models import ProductModel from product.models import ProductModel
from django.conf import settings from django.conf import settings
import openai import openai
@@ -55,7 +58,7 @@ class ProductChatModel(models.Model):
self.thread = thread.id self.thread = thread.id
except Exception as e: except Exception as e:
print(f'error in chat class: {e}') logger.error(f'error in chat class: {e}')
raise ValueError(f"Error creating OpenAI thread: {e}") raise ValueError(f"Error creating OpenAI thread: {e}")
super().save(*args, **kwargs) super().save(*args, **kwargs)
+1 -1
View File
@@ -136,7 +136,7 @@ AUTH_PASSWORD_VALIDATORS = [
] ]
LANGUAGE_CODE = "fa" LANGUAGE_CODE = "fa"
TIME_ZONE = "UTC" TIME_ZONE = "Asia/Tehran"
USE_I18N = True USE_I18N = True
USE_L10N = True USE_L10N = True
USE_TZ = True USE_TZ = True
+1 -1
View File
@@ -21,7 +21,7 @@ STATIC_ROOT = 'app/static'
USE_X_FORWARDED_HOST = True USE_X_FORWARDED_HOST = True
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
ALLOWED_HOSTS = ['127.0.0.1', 'localhost', DOMAIN, API_DOMAIN, '0.0.0.0', "185.110.189.208", 'www.google.com', 'google.com']
DATABASES = { DATABASES = {
'default': { 'default': {
+12
View File
@@ -171,6 +171,18 @@ UNFOLD = {
"link": reverse_lazy("admin:order_discountcode_changelist"), "link": reverse_lazy("admin:order_discountcode_changelist"),
"permission": lambda request: request.user.is_superuser, "permission": lambda request: request.user.is_superuser,
}, },
{
"title": _("نظرات کاربران"),
"icon": "comment",
"link": reverse_lazy("admin:product_commentmodel_changelist"),
"permission": lambda request: request.user.is_superuser,
},
{
"title": _("امتیاز کاربران"),
"icon": "star_rate_half",
"link": reverse_lazy("admin:product_productrating_changelist"),
"permission": lambda request: request.user.is_superuser,
},
], ],
}, },
+5 -4
View File
@@ -1,7 +1,10 @@
import json import json
import random import random
import logging
from functools import lru_cache from functools import lru_cache
logger = logging.getLogger(__name__)
from django.contrib.humanize.templatetags.humanize import intcomma from django.contrib.humanize.templatetags.humanize import intcomma
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -274,9 +277,7 @@ class FakeAdminLoginView(View):
ip = x_forwarded_for.split(',')[0] ip = x_forwarded_for.split(',')[0]
else: else:
ip = request.META.get("REMOTE_ADDR") ip = request.META.get("REMOTE_ADDR")
print(ip) logger.info(f"Honeypot GET request from IP: {ip}, length: {len(ip)}, type: {type(ip).__name__}")
print(len(ip))
print(type(ip))
hacker, created = SecurityBreachAttemptModel.objects.get_or_create(ip_address=ip) hacker, created = SecurityBreachAttemptModel.objects.get_or_create(ip_address=ip)
return render(request, 'admin/fake_login.html', self.get_context(request)) return render(request, 'admin/fake_login.html', self.get_context(request))
@@ -286,7 +287,7 @@ class FakeAdminLoginView(View):
ip = x_forwarded_for.split(',')[0] ip = x_forwarded_for.split(',')[0]
else: else:
ip = request.META.get("REMOTE_ADDR") ip = request.META.get("REMOTE_ADDR")
print(ip) logger.warning(f"Honeypot POST request from IP: {ip}")
hacker, created = SecurityBreachAttemptModel.objects.get_or_create(ip_address=ip) hacker, created = SecurityBreachAttemptModel.objects.get_or_create(ip_address=ip)
hacker.trys += 1 hacker.trys += 1
hacker.save() hacker.save()
+4 -2
View File
@@ -1,5 +1,8 @@
from django.contrib import admin, messages from django.contrib import admin, messages
from .models import * from .models import *
import logging
logger = logging.getLogger(__name__)
from unfold.admin import TabularInline, StackedInline from unfold.admin import TabularInline, StackedInline
from unfold.contrib.inlines.admin import NonrelatedTabularInline from unfold.contrib.inlines.admin import NonrelatedTabularInline
from django.db.models import Q from django.db.models import Q
@@ -35,8 +38,7 @@ class OrderItemAdmin(ModelAdmin):
if not hasattr(request.user, 'shop'): if not hasattr(request.user, 'shop'):
return False return False
print(obj.product.product.shop) logger.debug(f"Shop permissions check: obj.shop={obj.product.product.shop}, user.shop={request.user.shop}")
print(request.user.shop)
return request.user.shop == obj.product.product.shop return request.user.shop == obj.product.product.shop
+10 -2
View File
@@ -55,6 +55,10 @@ def generate_order_invoice(order_id):
qr_code_path = os.path.join(template_dir, 'qr-code.png') qr_code_path = os.path.join(template_dir, 'qr-code.png')
# Use stored model fields for accuracy and consistency # Use stored model fields for accuracy and consistency
# Format the Jalali date for display
jalali_date = to_jalali(order.created_at)
created_at_jalali_str = jalali_date.strftime('%Y-%m-%d %H:%M') if jalali_date else '---'
context = { context = {
'order': order, 'order': order,
'order_number': order.pk, 'order_number': order.pk,
@@ -63,7 +67,7 @@ def generate_order_invoice(order_id):
'user': order.user, 'user': order.user,
'address': order.address, 'address': order.address,
'discount_code': order.discount_code, 'discount_code': order.discount_code,
'created_at_jalali': to_jalali(order.created_at), 'created_at_jalali': created_at_jalali_str,
'total_items': sum(item.quantity for item in items), 'total_items': sum(item.quantity for item in items),
'subtotal': order.cart_total or 0, # Stored field: total before any discounts 'subtotal': order.cart_total or 0, # Stored field: total before any discounts
'items_discount_amount': total_items_discount, 'items_discount_amount': total_items_discount,
@@ -157,6 +161,10 @@ def generate_shop_order_invoice(shop_order_id):
# Calculate subtotal (cart total before discounts) # Calculate subtotal (cart total before discounts)
subtotal = shop_order.subtotal or 0 subtotal = shop_order.subtotal or 0
# Format the Jalali date for display
jalali_date = jdatetime.datetime.fromgregorian(datetime=shop_order.order_created_at) if shop_order.order_created_at else None
created_at_jalali_str = jalali_date.strftime('%Y-%m-%d %H:%M') if jalali_date else '---'
# Resolve image paths for the template (absolute file paths for WeasyPrint) # Resolve image paths for the template (absolute file paths for WeasyPrint)
import os import os
template_dir = os.path.join(settings.BASE_DIR, 'templates', 'order') template_dir = os.path.join(settings.BASE_DIR, 'templates', 'order')
@@ -181,7 +189,7 @@ def generate_shop_order_invoice(shop_order_id):
'address_recipient_name': shop_order.address_recipient_name, 'address_recipient_name': shop_order.address_recipient_name,
'items': items, 'items': items,
'items_with_discount': items_with_discount, 'items_with_discount': items_with_discount,
'created_at_jalali': jdatetime.datetime.fromgregorian(datetime=shop_order.order_created_at) if shop_order.order_created_at else None, 'created_at_jalali': created_at_jalali_str,
'total_items': shop_order.items_count, 'total_items': shop_order.items_count,
'subtotal': subtotal, # Total after product discount 'subtotal': subtotal, # Total after product discount
'items_discount_amount': total_items_discount, # Product discount amount 'items_discount_amount': total_items_discount, # Product discount amount
@@ -0,0 +1,21 @@
# Generated by Django 5.2 on 2026-05-12 05:46
import django.utils.timezone
import django_jalali.db.models
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('order', '0043_shopdailyreport_is_settled_and_more'),
]
operations = [
migrations.AlterField(
model_name='ordermodel',
name='created_at',
field=django_jalali.db.models.jDateTimeField(auto_now_add=True, default=django.utils.timezone.now, verbose_name='تاریخ ثبت سفارش'),
preserve_default=False,
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2 on 2026-05-12 05:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('order', '0044_alter_ordermodel_created_at'),
]
operations = [
migrations.AlterField(
model_name='ordermodel',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ثبت سفارش'),
),
]
+8 -7
View File
@@ -1,5 +1,8 @@
from account.models import SpecialDiscountCode from account.models import SpecialDiscountCode
from django.db import models, transaction from django.db import models, transaction
import logging
logger = logging.getLogger(__name__)
from account.models import User, UserAddressModel, PushSubscription from account.models import User, UserAddressModel, PushSubscription
from product.models import ProductModel, ProductVariant, ProductImageModel from product.models import ProductModel, ProductVariant, ProductImageModel
from django.utils import timezone from django.utils import timezone
@@ -33,7 +36,7 @@ class DiscountCode(models.Model):
elif not self.quantity > 0: elif not self.quantity > 0:
return 'این کد تخفیف تمام شده است' return 'این کد تخفیف تمام شده است'
else: else:
print('log later bug') logger.warning('Discount code validity check failed')
class Cart(models.Model): class Cart(models.Model):
@@ -168,8 +171,8 @@ class OrderModel(models.Model):
null=True, related_name='orders', verbose_name='کاربر') null=True, related_name='orders', verbose_name='کاربر')
address = models.ForeignKey(UserAddressModel, on_delete=models.SET_NULL, address = models.ForeignKey(UserAddressModel, on_delete=models.SET_NULL,
related_name='orders', null=True, verbose_name='ادرس') related_name='orders', null=True, verbose_name='ادرس')
created_at = jmodels.jDateField( created_at = models.DateTimeField(
blank=True, null=True, verbose_name="تاریخ ثبت سفارش") auto_now_add=True, verbose_name="تاریخ ثبت سفارش")
is_paid = models.BooleanField(default=False, verbose_name="وضعیت پرداخت") is_paid = models.BooleanField(default=False, verbose_name="وضعیت پرداخت")
discount_code = models.ForeignKey( discount_code = models.ForeignKey(
DiscountCode, on_delete=models.PROTECT, null=True, blank=True, verbose_name="کدتخفیف") DiscountCode, on_delete=models.PROTECT, null=True, blank=True, verbose_name="کدتخفیف")
@@ -227,9 +230,7 @@ class OrderModel(models.Model):
return True return True
except Exception as e: except Exception as e:
print(e) logger.error(f"Failed to rollback stock for order {self.pk}: {e}")
# Log the error if you have logging setup
# logger.error(f"Failed to rollback stock for order {self.pk}: {e}")
return False return False
@@ -264,7 +265,7 @@ class OrderItemModel(models.Model):
# @property # @property
def price_after_special_discount(self): def price_after_special_discount(self):
all_discounts = (self.special_discount_amount or 0) + self.total_product_discount_amount() all_discounts = (self.special_discount_amount or 0) + self.total_product_discount_amount()
print(all_discounts) logger.debug(f"Total discounts calculated: {all_discounts}")
return self.total_price_before_discount() - all_discounts return self.total_price_before_discount() - all_discounts
def unit_price(self): def unit_price(self):
+3 -3
View File
@@ -40,8 +40,8 @@ def send_change_status_notif(instance_pk, new_status):
for user_sub in user_subs: for user_sub in user_subs:
try: try:
user_sub.send_notif(f'سفارش شما به {new_status} تغییر کرد', f'سفارش شما به {new_status} تغییر کرد', ProductImageModel.objects.all().first().image.url) user_sub.send_notif(f'سفارش شما به {new_status} تغییر کرد', f'سفارش شما به {new_status} تغییر کرد', ProductImageModel.objects.all().first().image.url)
except: except Exception as e:
print('log later send notif error') logger.error('Error sending status notification: ' + str(e))
@shared_task @shared_task
def send_change_status_sms(instance_pk, new_status): def send_change_status_sms(instance_pk, new_status):
@@ -77,7 +77,7 @@ def generate_daily_shop_reports():
from .models import ShopOrderModel, ShopDailyReport from .models import ShopOrderModel, ShopDailyReport
target_date = (timezone.now() - timedelta(days=1)).date() target_date = (timezone.now() - timedelta(days=1)).date()
print(f'Generating shop reports for {target_date}') logging.info(f'Generating shop reports for {target_date}')
shop_orders = ShopOrderModel.objects.filter(created_at__date=target_date) shop_orders = ShopOrderModel.objects.filter(created_at__date=target_date)
if not shop_orders.exists(): if not shop_orders.exists():
+2 -2
View File
@@ -390,13 +390,13 @@ class PaymentView(APIView):
}) })
except AZBankGatewaysException as e: except AZBankGatewaysException as e:
print(f"Payment gateway error: {e}") logger.error(f"Payment gateway error: {e}")
return Response({ return Response({
'error': 'خطا در اتصال به درگاه پرداخت' 'error': 'خطا در اتصال به درگاه پرداخت'
}, status=status.HTTP_400_BAD_REQUEST) }, status=status.HTTP_400_BAD_REQUEST)
except Exception as e: except Exception as e:
print(f"Order creation error: {e}") logger.error(f"Order creation error: {e}")
return Response({ return Response({
'error': 'خطا در ثبت سفارش' 'error': 'خطا در ثبت سفارش'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
+44 -4
View File
@@ -1,5 +1,8 @@
from django.contrib import admin, messages from django.contrib import admin, messages
from django import forms from django import forms
import logging
logger = logging.getLogger(__name__)
# from product.tasks import update_prices # from product.tasks import update_prices
from .models import * from .models import *
from unfold.admin import TabularInline, StackedInline from unfold.admin import TabularInline, StackedInline
@@ -263,13 +266,13 @@ class ProductDetailModel1Admin(ModelAdmin, ImportExportModelAdmin):
def get_queryset(self, request): def get_queryset(self, request):
if request.user.is_superuser: if request.user.is_superuser:
print('here') logger.info('Returning all ProductDetailModels for superuser')
return ProductDetailModel.objects.all() return ProductDetailModel.objects.all()
if not hasattr(request.user, 'shop'): if not hasattr(request.user, 'shop'):
print(' in here 2') logger.info('User has no shop, returning empty queryset')
return ProductDetailModel.objects.none() return ProductDetailModel.objects.none()
print('in here 3') logger.info('Filtering ProductDetailModels by shop')
queryset = ProductDetailModel.objects.filter(product__product__shop__id=request.user.shop.id) queryset = ProductDetailModel.objects.filter(product__product__shop__id=request.user.shop.id)
return queryset return queryset
@@ -543,6 +546,20 @@ class CommentAdmin(ModelAdmin, ImportExportModelAdmin):
return obj.content[0:35] + '...' return obj.content[0:35] + '...'
display_content.short_description = 'محتوای کامنت' display_content.short_description = 'محتوای کامنت'
def has_view_permission(self, request, obj = ...):
return request.user.is_superuser
def has_add_permission(self, request):
return request.user.is_superuser
def has_change_permission(self, request, obj=None):
return request.user.is_superuser
def has_delete_permission(self, request, obj=None):
return request.user.is_superuser
@admin.register(DollorModel) @admin.register(DollorModel)
class DollorAdmin(ModelAdmin, ImportExportModelAdmin): class DollorAdmin(ModelAdmin, ImportExportModelAdmin):
import_form_class = ImportForm import_form_class = ImportForm
@@ -558,4 +575,27 @@ class DollorAdmin(ModelAdmin, ImportExportModelAdmin):
"widget": ArrayWidget, "widget": ArrayWidget,
} }
} }
readonly_fields = ('price',) readonly_fields = ('price',)
@admin.register(ProductRating)
class ProductRatingAdmin(ModelAdmin):
list_display = ('product', 'user', 'rating', 'created_at')
list_filter = ('rating', 'created_at')
search_fields = ('product__name', 'user__phone', 'user__first_name', 'user__last_name')
readonly_fields = ('product', 'user', 'created_at', 'updated_at')
date_hierarchy = 'created_at'
ordering = ('-created_at',)
compressed_fields = True
warn_unsaved_form = True
def has_view_permission(self, request, obj = ...):
return request.user.is_superuser
def has_add_permission(self, request):
return request.user.is_superuser
def has_change_permission(self, request, obj=None):
return request.user.is_superuser
def has_delete_permission(self, request, obj=None):
return request.user.is_superuser
@@ -0,0 +1,34 @@
# Generated by Django 5.2 on 2026-05-12 04:59
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('product', '0072_remove_productmodel_product_show_idx_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ProductRating',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('rating', models.PositiveIntegerField(choices=[(1, '1 - بسیار ضعیف'), (2, '2 - ضعیف'), (3, '3 - متوسط'), (4, '4 - خوب'), (5, '5 - عالی')], verbose_name='امتیاز')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ایجاد')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='تاریخ آپدیت')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ratings', to='product.productmodel', verbose_name='محصول')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='product_ratings', to=settings.AUTH_USER_MODEL, verbose_name='کاربر')),
],
options={
'verbose_name': 'امتیاز محصول',
'verbose_name_plural': 'امتیازات محصول',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['product'], name='rating_product_idx'), models.Index(fields=['user'], name='rating_user_idx'), models.Index(fields=['rating'], name='rating_rating_idx')],
'unique_together': {('product', 'user')},
},
),
]
+38 -1
View File
@@ -1,5 +1,8 @@
from django.db import models from django.db import models
from django.utils.text import slugify from django.utils.text import slugify
import logging
logger = logging.getLogger(__name__)
from account.models import User from account.models import User
from django.urls import reverse from django.urls import reverse
import requests import requests
@@ -148,7 +151,7 @@ class DollorModel(models.Model):
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
print('\n\nprice from api \n\n') logger.info('Price fetched from API')
except Exception as e: except Exception as e:
return self.defualt_price return self.defualt_price
@@ -493,3 +496,37 @@ class ProductVariant(DirtyFieldsMixin, models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.set_or_update_price() self.set_or_update_price()
super().save(*args, **kwargs) super().save(*args, **kwargs)
class ProductRating(models.Model):
RATING_CHOICES = (
(1, '1 - بسیار ضعیف'),
(2, '2 - ضعیف'),
(3, '3 - متوسط'),
(4, '4 - خوب'),
(5, '5 - عالی'),
)
product = models.ForeignKey(
ProductModel, on_delete=models.CASCADE, related_name='ratings', verbose_name='محصول')
user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name='product_ratings', verbose_name='کاربر')
rating = models.PositiveIntegerField(
choices=RATING_CHOICES, verbose_name='امتیاز')
created_at = models.DateTimeField(
auto_now_add=True, verbose_name='تاریخ ایجاد')
updated_at = models.DateTimeField(
auto_now=True, verbose_name='تاریخ آپدیت')
class Meta:
verbose_name = 'امتیاز محصول'
verbose_name_plural = 'امتیازات محصول'
unique_together = ('product', 'user')
ordering = ['-created_at']
indexes = [
models.Index(fields=['product'], name='rating_product_idx'),
models.Index(fields=['user'], name='rating_user_idx'),
models.Index(fields=['rating'], name='rating_rating_idx'),
]
def __str__(self):
return f"{self.user} - {self.product} ({self.rating}⭐)"
+71 -4
View File
@@ -158,7 +158,8 @@ class DynamicProductSerializer(serializers.ModelSerializer):
main_image = serializers.SerializerMethodField() main_image = serializers.SerializerMethodField()
customer_pickup_title = serializers.SerializerMethodField() customer_pickup_title = serializers.SerializerMethodField()
customer_pickup_description = serializers.SerializerMethodField() customer_pickup_description = serializers.SerializerMethodField()
average_rating = serializers.SerializerMethodField()
user_rating = serializers.SerializerMethodField()
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
view_type = self.context.get('view_type', 'all') view_type = self.context.get('view_type', 'all')
@@ -174,12 +175,23 @@ class DynamicProductSerializer(serializers.ModelSerializer):
model = ProductModel model = ProductModel
fields = "__all__" fields = "__all__"
view_type = { view_type = {
'list': ['id', 'name', 'rating', 'slug', 'category', 'colors', 'image', 'best_deal_price_before_discount', 'best_deal_price_after_discount', 'best_deal_discount', 'main_image'], 'list': ['id', 'name', 'rating', 'slug', 'category', 'colors', 'image', 'best_deal_price_before_discount', 'best_deal_price_after_discount', 'best_deal_discount', 'main_image', 'average_rating'],
'slider': ['id', 'name', 'rating', 'slug', 'category', 'variants', 'colors', 'image', 'best_deal_price_before_discount', 'best_deal_price_after_discount', 'best_deal_discount', ], 'slider': ['id', 'name', 'rating', 'slug', 'category', 'variants', 'colors', 'image', 'best_deal_price_before_discount', 'best_deal_price_after_discount', 'best_deal_discount', 'average_rating'],
'instance': ['id', 'name', 'description', 'rating', 'slug', 'meta_description', 'meta_keywords', 'meta_rating', 'category', 'related_products', 'in_pack_items', 'variants', 'colors', 'added_to_favorites', 'image', 'customer_pickup_title', 'customer_pickup_description'], 'instance': ['id', 'name', 'description', 'rating', 'slug', 'meta_description', 'meta_keywords', 'meta_rating', 'category', 'related_products', 'in_pack_items', 'variants', 'colors', 'added_to_favorites', 'image', 'customer_pickup_title', 'customer_pickup_description', 'average_rating', 'user_rating'],
'chat': ['id', 'name', 'description', 'variants', 'image'] 'chat': ['id', 'name', 'description', 'variants', 'image']
} }
def get_user_rating(self, obj):
request = self.context.get('request')
if not request.user.is_authenticated:
return None
product_ratings = obj.ratings.all()
if product_ratings.filter(user=request.user).exists():
return product_ratings.filter(user=request.user).first().rating
else:
return None
def _get_best_deal_variant(self, obj): def _get_best_deal_variant(self, obj):
"""Get best deal variant from prefetched variants (pre-ordered by discount/price)""" """Get best deal variant from prefetched variants (pre-ordered by discount/price)"""
if not hasattr(self, '_best_deal_cache'): if not hasattr(self, '_best_deal_cache'):
@@ -241,6 +253,39 @@ class DynamicProductSerializer(serializers.ModelSerializer):
# Use exists() with filter instead of fetching all products # Use exists() with filter instead of fetching all products
return UserFavorites.objects.filter(user=request.user, products=obj).exists() return UserFavorites.objects.filter(user=request.user, products=obj).exists()
def get_average_rating(self, obj):
"""Get cached average rating for product - optimized with prefetch"""
from django.core.cache import cache
from django.db.models import Avg
cache_key = f'product_avg_rating_{obj.id}'
avg_rating = cache.get(cache_key)
if avg_rating is None:
# Try to use prefetched ratings if available (no query needed)
try:
prefetched_ratings = obj._prefetched_objects_cache.get('ratings')
if prefetched_ratings is not None:
# Use prefetched data - no database query
ratings_list = [r.rating for r in prefetched_ratings]
if ratings_list:
avg_rating = round(sum(ratings_list) / len(ratings_list), 2)
else:
avg_rating = 0
else:
# Fall back to aggregation query if not prefetched
avg_rating = obj.ratings.aggregate(Avg('rating'))['rating__avg'] or 0
avg_rating = round(avg_rating, 2)
except (AttributeError, KeyError):
# Fall back to aggregation query
avg_rating = obj.ratings.aggregate(Avg('rating'))['rating__avg'] or 0
avg_rating = round(avg_rating, 2)
# Cache for 1 hour
cache.set(cache_key, avg_rating, 3600)
return avg_rating
def get_variants(self, obj): def get_variants(self, obj):
view_type = self.context.get('view_type') view_type = self.context.get('view_type')
if view_type == 'slider': if view_type == 'slider':
@@ -292,3 +337,25 @@ class BotProductSerializer(serializers.ModelSerializer):
'pk', 'pk',
'name' 'name'
] ]
class ProductRatingSerializer(serializers.ModelSerializer):
class Meta:
model = ProductRating
fields = ['rating']
def validate_rating(self, value):
if value not in [1, 2, 3, 4, 5]:
raise serializers.ValidationError("امتیاز باید بین 1 تا 5 باشد")
return value
def create(self, validated_data):
product_id = self.context.get('product_id')
user = self.context.get('request').user
rating = ProductRating.objects.create(
product_id=product_id,
user=user,
rating=validated_data['rating']
)
return rating
+8 -2
View File
@@ -1,5 +1,10 @@
from django.urls import path, re_path from django.urls import path, re_path
from .views import AllCategories, ProductView, AllProductsView, CommentView, ShowCaseProductsView, ShowCaseCategoryListView, BotProductsView,BotProductDetailView,BotCategoryView ,AllCategoriesV2 from .views import (
AllCategories, ProductView, AllProductsView, CommentView,
ShowCaseProductsView, ShowCaseCategoryListView, BotProductsView,
BotProductDetailView, BotCategoryView, AllCategoriesV2,
ProductRatingView
)
urlpatterns = [ urlpatterns = [
path('slider_category', ShowCaseProductsView.as_view(), name='category-products'), path('slider_category', ShowCaseProductsView.as_view(), name='category-products'),
@@ -10,6 +15,7 @@ urlpatterns = [
path('categories/bot', BotCategoryView.as_view(), name='bot-categories'), path('categories/bot', BotCategoryView.as_view(), name='bot-categories'),
path('slider_categories', ShowCaseCategoryListView.as_view(), name='all-categories'), path('slider_categories', ShowCaseCategoryListView.as_view(), name='all-categories'),
re_path(r'^comments/(?P<slug>[\w\u0600-\u06FF\-]+)$', CommentView.as_view(), name='comment-views'), re_path(r'^comments/(?P<slug>[\w\u0600-\u06FF\-]+)$', CommentView.as_view(), name='comment-views'),
re_path(r'^(?P<slug>[\w\u0600-\u06FF\-]+)/rating/$', ProductRatingView.as_view(), name='product-rating'),
re_path(r'^(?P<slug>[\w\u0600-\u06FF\-]+)/$', ProductView.as_view(), name='product-detail'), re_path(r'^(?P<slug>[\w\u0600-\u06FF\-]+)/$', ProductView.as_view(), name='product-detail'),
path('', AllProductsView.as_view(), name='category-products'), path('', AllProductsView.as_view(), name='category-products'),
] ]
+61
View File
@@ -134,7 +134,15 @@ class ProductView(APIView):
permission_classes = [AllowAny] permission_classes = [AllowAny]
# authentication_classes = [] # authentication_classes = []
@extend_schema(
responses={
200: DynamicProductSerializer(context={'view_type': 'instance'}),
404: OpenApiTypes.OBJECT,
},
)
def get(self, request, slug): def get(self, request, slug):
from django.db.models import F
# Optimize query with select_related and prefetch_related to avoid N+1 queries # Optimize query with select_related and prefetch_related to avoid N+1 queries
product = get_object_or_404( product = get_object_or_404(
ProductModel.objects.select_related( ProductModel.objects.select_related(
@@ -147,10 +155,15 @@ class ProductView(APIView):
'variants__details__detail_category', 'variants__details__detail_category',
'related_products__variants__product_attributes', 'related_products__variants__product_attributes',
'related_products__category', 'related_products__category',
'ratings',
), ),
slug=slug slug=slug
) )
# Increment product view count atomically using F expressions to avoid race conditions
ProductModel.objects.filter(id=product.id).update(view=F('view') + 1)
product.view += 1 # Update in-memory instance for response
if request.user.is_authenticated: if request.user.is_authenticated:
cart_obj, _ = Cart.objects.get_or_create(user=request.user) cart_obj, _ = Cart.objects.get_or_create(user=request.user)
# Optimize cart items query - prefetch all related data # Optimize cart items query - prefetch all related data
@@ -662,3 +675,51 @@ class BotCategoryView(APIView):
"success": False, "success": False,
"categories": [] "categories": []
}) })
class ProductRatingView(APIView):
"""
API endpoint to submit/update a product rating
POST: /api/products/<slug>/rating/
"""
permission_classes = [IsAuthenticatedOrReadOnly]
serializer_class = ProductRatingSerializer
def post(self, request, slug):
if not request.user.is_authenticated:
return Response(
{'detail': 'احراز هویت الزامی است'},
status=status.HTTP_401_UNAUTHORIZED
)
product = get_object_or_404(ProductModel, slug=slug)
# Check if user already rated this product
existing_rating = ProductRating.objects.filter(
product=product,
user=request.user
).exists()
if existing_rating:
return Response(
{'detail': 'شما قبلا این محصول را امتیاز دادید'},
status=status.HTTP_400_BAD_REQUEST
)
serializer = ProductRatingSerializer(
data=request.data,
context={'product_id': product.id, 'request': request}
)
if serializer.is_valid():
serializer.save()
# Invalidate cache for this product
from django.core.cache import cache
cache.delete(f'product_avg_rating_{product.id}')
return Response(
{'detail': 'امتیاز شما با موفقیت ثبت شد'},
status=status.HTTP_201_CREATED
)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+19 -12
View File
@@ -124,14 +124,17 @@
.info-flex { .info-flex {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 4px 12px; gap: 6px 12px;
align-items: flex-start;
} }
.info-item { .info-item {
display: flex; display: flex;
gap: 3px; gap: 3px;
white-space: nowrap;
font-size: 9px; font-size: 9px;
min-width: 0;
flex-wrap: wrap;
word-break: break-word;
} }
.info-item span:first-child { .info-item span:first-child {
@@ -310,22 +313,22 @@
<span>فرستنده :</span><span>فروشگاه هی ملز</span> <span>فرستنده :</span><span>فروشگاه هی ملز</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span>شناسه ملی :</span><span>---</span> <span>شناسه ملی :</span><span>14014423086</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span>شماره ثبت :</span><span>---</span> <span>شماره ثبت :</span><span>64852</span>
</div> </div>
{% comment %} <div class="info-item">
<span>شماره مجوز :</span><span>-</span>
</div> {% endcomment %}
<div class="info-item"> <div class="info-item">
<span>شماره مجوز :</span><span>---</span> <span>نشانی شرکت :</span><span>شیراز بلوار گلستان بلوار سما خیابان طهماسبی پلاک ۱۹۸</span>
</div> </div>
<div class="info-item"> <div class="info-item" style="flex-basis: 100%;">
<span>نشانی شرکت :</span><span>---</span> <span>کد پستی :</span><span>7145746584</span>
</div> </div>
<div class="info-item"> <div class="info-item" style="flex-basis: 100%;">
<span>کد پستی :</span><span>---</span> <span>تلفن و فکس :</span><span>02193111026</span>
</div>
<div class="info-item">
<span>تلفن و فکس :</span><span>---</span>
</div> </div>
</div> </div>
</div> </div>
@@ -492,6 +495,10 @@
<div class="footer"> <div class="footer">
<p>با تشکر از خرید شما</p> <p>با تشکر از خرید شما</p>
<p>این فاکتور به صورت الکترونیکی صادر شده و نیازی به امضا و مهر ندارد</p> <p>این فاکتور به صورت الکترونیکی صادر شده و نیازی به امضا و مهر ندارد</p>
<p>«هرگونه عودت یا ارسال کالا، صرفاً پس از هماهنگی با واحد پشتیبانی شرکت امکان‌پذیر است.»</p>
<p>«مشتری موظف است پیش از هرگونه مرجوعی یا ارسال، با پشتیبانی شرکت هماهنگی لازم را انجام دهد.»</p>
<p>«عودت، تعویض یا ارسال کالا بدون هماهنگی قبلی با شرکت مورد پذیرش نخواهد بود.</p>
</div> </div>
</div> </div>
</body> </body>
@@ -371,25 +371,22 @@
<div class="info-panel"> <div class="info-panel">
<div class="info-flex"> <div class="info-flex">
<div class="info-item"> <div class="info-item">
<span>فرستنده :</span><span>{{ shop.shop_name }}</span> <span>فرستنده :</span><span>فروشگاه هی ملز</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span>شناسه ملی :</span><span>---</span> <span>شناسه ملی :</span><span>14014423086</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span>شماره ثبت :</span><span>---</span> <span>شماره ثبت :</span><span>64852</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span>شماره مجوز :</span><span>---</span> <span>نشانی شرکت :</span><span>شیراز بلوار گلستان بلوار سما خیابان طهماسبی پلاک ۱۹۸</span>
</div> </div>
<div class="info-item"> <div class="info-item" style="flex-basis: 100%;">
<span>نشانی شرکت :</span><span>---</span> <span>کد پستی :</span><span>7145746584</span>
</div> </div>
<div class="info-item"> <div class="info-item" style="flex-basis: 100%;">
<span>کد پستی :</span><span>---</span> <span>تلفن و فکس :</span><span>02193111026</span>
</div>
<div class="info-item">
<span>تلفن و فکس :</span><span>---</span>
</div> </div>
</div> </div>
</div> </div>
@@ -324,8 +324,8 @@ watch(
:to="{ name: nextPage?.name, query: { gw: nextPage?.query } }" :to="{ name: nextPage?.name, query: { gw: nextPage?.query } }"
> >
<Button <Button
start-icon="arrow-right" start-icon="ci:arrow-right"
class="w-full rounded-100" class="w-full rounded-100 py-2 lg:typo-p-sm"
variant="primary" variant="primary"
> >
{{ nextPage?.label }} {{ nextPage?.label }}
+13 -13
View File
@@ -59,7 +59,7 @@ const handleDeleteFromCart = () => {
}, },
}); });
}, },
} },
); );
}; };
@@ -106,9 +106,9 @@ watch(
}, },
}); });
}, },
} },
); );
} },
); );
</script> </script>
@@ -117,8 +117,12 @@ watch(
class="flex flex-col items-center w-full gap-4 p-4 border lg:flex-row border-slate-200 rounded-xl bg-slate-50 overflow-hidden relative" class="flex flex-col items-center w-full gap-4 p-4 border lg:flex-row border-slate-200 rounded-xl bg-slate-50 overflow-hidden relative"
> >
<div class="flex items-start justify-start w-full gap-2.5 lg:gap-4"> <div class="flex items-start justify-start w-full gap-2.5 lg:gap-4">
<Skeleton
v-if="cartImageIsLoading"
class="size-[4rem] lg:!size-[12rem] aspect-square shrink-0 !rounded-xl border border-slate-200"
/>
<div <div
v-if="!cartImageIsLoading" v-else
class="size-[4rem] lg:size-[12rem] aspect-square shrink-0 rounded-xl border border-slate-200 overflow-hidden" class="size-[4rem] lg:size-[12rem] aspect-square shrink-0 rounded-xl border border-slate-200 overflow-hidden"
> >
<NuxtImg <NuxtImg
@@ -129,10 +133,6 @@ watch(
alt="product" alt="product"
/> />
</div> </div>
<Skeleton
v-else
class="!size-[12rem] aspect-square shrink-0 !rounded-xl border border-slate-200"
/>
<div class="flex flex-col w-full gap-3 lg:gap-4"> <div class="flex flex-col w-full gap-3 lg:gap-4">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
@@ -142,7 +142,7 @@ watch(
<div class="w-max flex-center gap-2"> <div class="w-max flex-center gap-2">
<div <div
v-if="data.discount > 0" v-if="data.discount > 0"
class="text-white bg-blue-500 px-3 lg:px-4 py-1.5 lg:py-2 text-[10px] lg:text-xs rounded-full flex items-center gap-1" class="text-white whitespace-pre bg-blue-500 px-3 lg:px-4 py-1.5 lg:py-2 text-[8px] lg:text-xs rounded-full flex items-center gap-1"
> >
{{ data.discount_amount }} {{ data.discount_amount }}
تخفیف تخفیف
@@ -164,7 +164,7 @@ watch(
{{ data.product.title }} {{ data.product.title }}
</NuxtLink> </NuxtLink>
<div class="flex items-center justify-start gap-1.5"> <div class="flex items-center justify-start flex-wrap gap-1.5">
<div <div
v-if="!!data.product.color" v-if="!!data.product.color"
class="px-3 py-1 rounded-full border border-slate-200 text-xs lg:text-sm flex-center gap-1.5" class="px-3 py-1 rounded-full border border-slate-200 text-xs lg:text-sm flex-center gap-1.5"
@@ -182,7 +182,7 @@ watch(
v-if="data.product.product_attributes.length > 0" v-if="data.product.product_attributes.length > 0"
v-for="(variant, index) in data.product.product_attributes" v-for="(variant, index) in data.product.product_attributes"
:index="index" :index="index"
class="px-3 py-1 rounded-full border border-slate-200 text-xs lg:text-sm" class="px-3 py-1 whitespace-pre rounded-full border border-slate-200 text-xs lg:text-sm"
> >
{{ variant.value }} {{ variant.value }}
</span> </span>
@@ -305,7 +305,7 @@ watch(
<div class="flex flex-col items-end"> <div class="flex flex-col items-end">
<span <span
v-if="data.discount > 0" v-if="data.discount > 0"
class="typo-p-xs relative flex-center w-fit line-through text-slate-400" class="text-[10px] lg:typo-p-xs relative flex-center w-fit line-through text-slate-400"
> >
{{ data.price }} {{ data.price }}
</span> </span>
@@ -315,7 +315,7 @@ watch(
> >
تخفیف ویژه: {{ data.special_discount_amount }} تخفیف ویژه: {{ data.special_discount_amount }}
</span> </span>
<span class="typo-p-md relative flex-center w-fit font-medium"> <span class="typo-p-sm lg:typo-p-md relative flex-center w-fit font-medium">
{{ data.final_price }} {{ data.final_price }}
</span> </span>
</div> </div>
@@ -54,8 +54,7 @@ const handleSubmit = () => {
> >
<template #trigger> <template #trigger>
<Button <Button
class="rounded-full shrink-0 whitespace-pre" class="rounded-full shrink-0 whitespace-pre max-lg:text-[10px]! max-lg:py-1"
end-icon="ci:bi-trash"
size="md" size="md"
> >
حذف همه حذف همه
+28 -34
View File
@@ -50,50 +50,44 @@ const isShow = computed({
class="bg-black/50 backdrop-blur-sm data-[state=open]:animate-overlay-show fixed inset-0 z-999" class="bg-black/50 backdrop-blur-sm data-[state=open]:animate-overlay-show fixed inset-0 z-999"
/> />
<div <DialogContent
class="fixed inset-0 w-full h-svh z-9999 flex-center" class="fixed inset-0 z-9999 flex items-start justify-center overflow-y-auto overscroll-contain data-[state=open]:animate-content-show focus:outline-none"
v-if="isShow" style="touch-action: pan-y"
> >
<div <div
:class="contectClass" :class="contectClass"
class="overflow-y-auto max-h-svh absolute left-[50%] py-10 w-fit max-w-[50rem] translate-x-[-50%]" class="relative w-fit max-w-[50rem] my-auto py-10"
> >
<DialogContent <div
class="data-[state=open]:animate-content-show text-black font-iran-yekan-x focus:outline-none z-[100]" class="rounded-2xl bg-white text-black font-iran-yekan-x shadow-[hsl(206_22%_7%_/_35%)_0px_10px_38px_-10px,_hsl(206_22%_7%_/_20%)_0px_10px_20px_-15px]"
> >
<div <div
class="rounded-2xl bg-white shadow-[hsl(206_22%_7%_/_35%)_0px_10px_38px_-10px,_hsl(206_22%_7%_/_20%)_0px_10px_20px_-15px]" class="w-full flex items-center px-6 justify-between py-[1.5rem] border-b border-slate-200"
> >
<div <DialogClose
class="w-full flex items-center px-6 justify-between py-[1.5rem] border-b border-slate-200" class="inline-flex size-8 items-center justify-center transition-all rounded-full bg-gray-50 border border-slate-200 hover:border-black focus:outline-none"
aria-label="Close"
> >
<DialogClose <Icon
class="inline-flex size-8 items-center justify-center transition-all rounded-full bg-gray-50 border border-slate-200 hover:border-black focus:outline-none" name="ci:bi-x-lg"
aria-label="Close" class="**:fill-black"
> />
<Icon </DialogClose>
name="ci:bi-x-lg" <DialogTitle class="typo-sub-h-md lg:typo-sub-h-xl font-semibold flex items-center gap-3">
class="**:fill-black" {{ title }}
/> <Icon
</DialogClose> v-if="!!icon"
<DialogTitle :name="icon"
class="typo-sub-h-md lg:typo-sub-h-xl font-semibold flex items-center gap-3" :size="iconSize"
> />
{{ title }} </DialogTitle>
<Icon
v-if="!!icon"
:name="icon"
:size="iconSize"
/>
</DialogTitle>
</div>
<div class="w-full px-6">
<slot name="content" />
</div>
</div> </div>
</DialogContent> <div class="w-full px-6">
<slot name="content" />
</div>
</div>
</div> </div>
</div> </DialogContent>
</DialogPortal> </DialogPortal>
</DialogRoot> </DialogRoot>
</template> </template>
@@ -124,7 +124,7 @@ const resetAvatarFile = async () => {
</button> </button>
</div> </div>
<div class="w-full flex-center gap-4 max-w-[500px] flex-wrap"> <!-- <div class="w-full flex-center gap-4 max-w-[500px] flex-wrap">
<button <button
class="size-6 lg:size-8 rounded-full bg-orange-100 whitespace-nowrap ring-2 hover:ring-black ring-slate-200 ring-offset-3" class="size-6 lg:size-8 rounded-full bg-orange-100 whitespace-nowrap ring-2 hover:ring-black ring-slate-200 ring-offset-3"
/> />
@@ -140,7 +140,7 @@ const resetAvatarFile = async () => {
<button <button
class="size-6 lg:size-8 rounded-full bg-amber-800 whitespace-nowrap ring-2 hover:ring-black ring-slate-200 ring-offset-3" class="size-6 lg:size-8 rounded-full bg-amber-800 whitespace-nowrap ring-2 hover:ring-black ring-slate-200 ring-offset-3"
/> />
</div> </div> -->
<div class="w-full flex-col-center gap-5"> <div class="w-full flex-col-center gap-5">
<Avatar <Avatar
+2 -2
View File
@@ -40,7 +40,7 @@ const hasCartItem = computed(() => !!cart.value && cart.value.items.length! > 0)
class="flex items-center gap-2 text-sm lg:text-[1rem] font-medium" class="flex items-center gap-2 text-sm lg:text-[1rem] font-medium"
> >
<Icon <Icon
name="ci:bi-arrow-right" name="ci:arrow-right"
class="**:stroke-blue-500" class="**:stroke-blue-500"
/> />
<span class="text-blue-500"> <span class="text-blue-500">
@@ -49,7 +49,7 @@ const hasCartItem = computed(() => !!cart.value && cart.value.items.length! > 0)
</NuxtLink> </NuxtLink>
</div> </div>
<h1 class="w-full text-center lg:w-6/12 typo-h-5 lg:typo-h-4"> <h1 v-if="pageTitle" class="w-full text-center lg:w-6/12 typo-h-5 lg:typo-h-4">
{{ pageTitle }} {{ pageTitle }}
</h1> </h1>
+1 -1
View File
@@ -29,7 +29,7 @@ const hasCartItem = computed(() => !!cart.value && cart.value.items.length! > 0)
<div class="w-full flex flex-col gap-4 lg:gap-6"> <div class="w-full flex flex-col gap-4 lg:gap-6">
<div <div
v-if="hasCartItem" v-if="hasCartItem"
class="flex items-center justify-between w-full gap-3 px-4 py-4 rounded-xl bg-slate-50 border border-slate-200" class="flex items-center justify-between w-full gap-3 px-4 py-2 lg:py-4 rounded-xl bg-slate-50 border border-slate-200"
> >
<Skeleton <Skeleton
v-if="cartIsLoading" v-if="cartIsLoading"