This commit is contained in:
Mamalizz
2025-02-21 23:14:10 +03:30
63 changed files with 1464 additions and 364 deletions
+40 -1
View File
@@ -10,6 +10,12 @@ from django.contrib.auth.models import Group
from unfold.forms import AdminPasswordChangeForm from unfold.forms import AdminPasswordChangeForm
from unfold.forms import AdminPasswordChangeForm, UserChangeForm, UserCreationForm from unfold.forms import AdminPasswordChangeForm, UserChangeForm, UserCreationForm
from utils.admin import ModelAdmin from utils.admin import ModelAdmin
from django.template.loader import render_to_string
from folium import Map, Marker
from unfold.decorators import action, display
from django.utils.html import format_html
class UserAddressInLine(TabularInline): class UserAddressInLine(TabularInline):
model = UserAddressModel model = UserAddressModel
extra = 0 extra = 0
@@ -95,4 +101,37 @@ class PushSubscription(ModelAdmin, ImportExportModelAdmin):
import_form_class = ImportForm import_form_class = ImportForm
export_form_class = ExportForm export_form_class = ExportForm
compressed_fields = True compressed_fields = True
warn_unsaved_form = True warn_unsaved_form = True
@admin.register(SecurityBreachAttemptModel)
class SecurityBreachAttemptAdmin(ModelAdmin, ImportExportModelAdmin):
import_form_class = ImportForm
export_form_class = ExportForm
compressed_fields = True
warn_unsaved_form = True
change_form_template = 'loction_chagne_form.html'
list_display = ['ip_address', 'country', 'region_name', 'city', 'zip_code', 'isp', 'created_at', 'trys', 'display_viewd']
def change_view(self, request, object_id, form_url='', extra_context=None):
extra_context = extra_context or {}
obj = self.get_object(request, object_id)
if obj and obj.lat and obj.lon:
m = Map(location=[obj.lat, obj.lon], zoom_start=10)
Marker([obj.lat, obj.lon], popup=f"Location: {obj.ip_address}").add_to(m)
map_html = m._repr_html_()
extra_context['map_html'] = map_html
return super().change_view(request, object_id, form_url, extra_context)
@display(description='دیده شده')
def display_viewd(self, instance):
if instance.viewd:
svg = f'<a href="/accounts/attack/view/{instance.id}"><svg xmlns="http://www.w3.org/2000/svg" width="25" height="25" viewBox="0 0 24 24"><path fill="green" d="M12 16q1.875 0 3.188-1.312T16.5 11.5t-1.312-3.187T12 7T8.813 8.313T7.5 11.5t1.313 3.188T12 16m0-1.8q-1.125 0-1.912-.788T9.3 11.5t.788-1.912T12 8.8t1.913.788t.787 1.912t-.787 1.913T12 14.2m0 4.8q-3.65 0-6.65-2.037T1 11.5q1.35-3.425 4.35-5.462T12 4t6.65 2.038T23 11.5q-1.35 3.425-4.35 5.463T12 19m0-2q2.825 0 5.188-1.487T20.8 11.5q-1.25-2.525-3.613-4.012T12 6T6.813 7.488T3.2 11.5q1.25 2.525 3.613 4.013T12 17"/></svg></a>'
else:
svg = f'<a href="/accounts/attack/view/{instance.id}"><svg xmlns="http://www.w3.org/2000/svg" width="25" height="25" viewBox="0 0 24 24"><path fill="#c30009" d="m19.1 21.9l-3.5-3.45q-.875.275-1.775.413T12 19q-3.35 0-6.125-1.8t-4.35-4.75q-.125-.225-.187-.462t-.063-.488t.063-.488t.187-.462q.55-.975 1.175-1.9T4.15 7L2.075 4.9Q1.8 4.625 1.8 4.213t.3-.713q.275-.275.7-.275t.7.275l17 17q.275.275.288.688t-.288.712q-.275.275-.7.275t-.7-.275M12 16q.275 0 .525-.025t.5-.1l-5.4-5.4q-.075.25-.1.5T7.5 11.5q0 1.875 1.313 3.188T12 16m0-12q3.35 0 6.138 1.813t4.362 4.762q.125.2.188.438t.062.487t-.05.488t-.175.437q-.475.925-1.062 1.75t-1.313 1.55q-.35.35-.825.325t-.825-.375l-2-2q-.175-.175-.225-.413t.025-.487q.1-.325.15-.625t.05-.65q0-1.875-1.312-3.187T12 7q-.35 0-.65.05t-.625.15q-.25.075-.5.025T9.8 7l-.825-.825q-.475-.475-.312-1.1t.787-.8q.625-.125 1.263-.2T12 4m1.975 5.65q.275.325.462.713t.238.812q.025.2-.15.275t-.325-.075l-2.05-2.05Q12 9.175 12.088 9t.287-.175q.475.05.875.263t.725.562"/></svg></a>'
return format_html(
svg
)
@@ -0,0 +1,31 @@
# Generated by Django 5.1.2 on 2025-02-20 20:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0014_alter_pushsubscription_created_at_and_more'),
]
operations = [
migrations.CreateModel(
name='SecurityBreachAttemptModel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ip', models.CharField(max_length=40, unique=True, verbose_name='آدرس آی\u200cپی')),
('country', models.CharField(blank=True, max_length=40, null=True, verbose_name='کشور')),
('region_name', models.CharField(blank=True, max_length=40, null=True, verbose_name='منطقه')),
('city', models.CharField(blank=True, max_length=40, null=True, verbose_name='شهر')),
('zip_code', models.CharField(blank=True, max_length=40, null=True, verbose_name='کد پستی')),
('lon', models.CharField(blank=True, max_length=40, null=True, verbose_name='طول جغرافیایی')),
('lat', models.CharField(blank=True, max_length=40, null=True, verbose_name='عرض جغرافیایی')),
('isp', models.CharField(blank=True, max_length=40, null=True, verbose_name='ارائه\u200cدهنده اینترنت (ISP)')),
],
options={
'verbose_name': 'تلاش نفوذ',
'verbose_name_plural': 'تلاش\u200cهای نفوذ',
},
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.2 on 2025-02-20 21:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0015_securitybreachattemptmodel'),
]
operations = [
migrations.AddField(
model_name='securitybreachattemptmodel',
name='viewd',
field=models.BooleanField(default=False, verbose_name='تماشا شده'),
),
]
@@ -0,0 +1,25 @@
# Generated by Django 5.1.2 on 2025-02-20 21:35
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0016_securitybreachattemptmodel_viewd'),
]
operations = [
migrations.AddField(
model_name='securitybreachattemptmodel',
name='created_at',
field=models.DateField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='securitybreachattemptmodel',
name='trys',
field=models.IntegerField(default=0),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.1.2 on 2025-02-20 21:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0017_securitybreachattemptmodel_created_at_and_more'),
]
operations = [
migrations.AlterField(
model_name='securitybreachattemptmodel',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='شروع حمله'),
),
migrations.AlterField(
model_name='securitybreachattemptmodel',
name='trys',
field=models.IntegerField(default=0, verbose_name='تعداد تلاش ها'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.2 on 2025-02-20 22:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0018_alter_securitybreachattemptmodel_created_at_and_more'),
]
operations = [
migrations.AlterField(
model_name='securitybreachattemptmodel',
name='ip',
field=models.CharField(max_length=100, unique=True, verbose_name='آدرس آی\u200cپی'),
),
]
@@ -0,0 +1,17 @@
# Generated by Django 5.1.2 on 2025-02-20 22:25
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('account', '0019_alter_securitybreachattemptmodel_ip'),
]
operations = [
migrations.RemoveField(
model_name='securitybreachattemptmodel',
name='ip',
),
]
@@ -0,0 +1,19 @@
# Generated by Django 5.1.2 on 2025-02-20 22:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0020_remove_securitybreachattemptmodel_ip'),
]
operations = [
migrations.AddField(
model_name='securitybreachattemptmodel',
name='ip',
field=models.CharField(default='', max_length=100, verbose_name='آدرس آی\u200cپی'),
preserve_default=False,
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.1.2 on 2025-02-20 22:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0021_securitybreachattemptmodel_ip'),
]
operations = [
migrations.RemoveField(
model_name='securitybreachattemptmodel',
name='ip',
),
migrations.AddField(
model_name='securitybreachattemptmodel',
name='ip_address',
field=models.CharField(default='', max_length=100, verbose_name='آدرس آی\u200cپی'),
preserve_default=False,
),
]
@@ -0,0 +1,48 @@
# Generated by Django 5.1.2 on 2025-02-20 22:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0022_remove_securitybreachattemptmodel_ip_and_more'),
]
operations = [
migrations.AlterField(
model_name='securitybreachattemptmodel',
name='city',
field=models.CharField(blank=True, max_length=103, null=True, verbose_name='شهر'),
),
migrations.AlterField(
model_name='securitybreachattemptmodel',
name='country',
field=models.CharField(blank=True, max_length=101, null=True, verbose_name='کشور'),
),
migrations.AlterField(
model_name='securitybreachattemptmodel',
name='isp',
field=models.CharField(blank=True, max_length=107, null=True, verbose_name='ارائه\u200cدهنده اینترنت (ISP)'),
),
migrations.AlterField(
model_name='securitybreachattemptmodel',
name='lat',
field=models.CharField(blank=True, max_length=106, null=True, verbose_name='عرض جغرافیایی'),
),
migrations.AlterField(
model_name='securitybreachattemptmodel',
name='lon',
field=models.CharField(blank=True, max_length=105, null=True, verbose_name='طول جغرافیایی'),
),
migrations.AlterField(
model_name='securitybreachattemptmodel',
name='region_name',
field=models.CharField(blank=True, max_length=102, null=True, verbose_name='منطقه'),
),
migrations.AlterField(
model_name='securitybreachattemptmodel',
name='zip_code',
field=models.CharField(blank=True, max_length=104, null=True, verbose_name='کد پستی'),
),
]
+44 -1
View File
@@ -8,6 +8,7 @@ from rest_framework_simplejwt.token_blacklist.models import BlacklistedToken, Ou
import hashlib import hashlib
from django.contrib import admin from django.contrib import admin
from django.conf import settings from django.conf import settings
import requests
class UserManager(BaseUserManager): class UserManager(BaseUserManager):
def create_user(self, phone, password=None): def create_user(self, phone, password=None):
if not phone: if not phone:
@@ -198,4 +199,46 @@ class PushSubscription(models.Model):
} }
) )
except WebPushException as ex: except WebPushException as ex:
print(f"Failed to send notification to {sub.user}:", ex) print(f"Failed to send notification to {sub.user}:", ex)
def get_location_from_ip(ip_address):
try:
response = requests.get(f"http://ip-api.com/json/{ip_address}")
data = response.json()
if data["status"] == "success":
return data['country'], data['regionName'], data['city'], data.get('zip', 'ناموجود'), data['lat'], data['lon'], data['isp']
else:
print("Error fetching data: ", data["message"])
return None
except Exception as e:
print(f"An error occurred: {e}")
return None
class SecurityBreachAttemptModel(models.Model):
ip_address = models.CharField(max_length=100, verbose_name="آدرس آی‌پی")
country = models.CharField(max_length=101, verbose_name="کشور", blank=True, null=True)
region_name = models.CharField(max_length=102, verbose_name="منطقه", blank=True, null=True)
city = models.CharField(max_length=103, verbose_name="شهر", blank=True, null=True)
zip_code = models.CharField(max_length=104, verbose_name="کد پستی", blank=True, null=True)
lon = models.CharField(max_length=105, verbose_name="طول جغرافیایی", blank=True, null=True)
lat = models.CharField(max_length=106, verbose_name="عرض جغرافیایی", blank=True, null=True)
isp = models.CharField(max_length=107, verbose_name="ارائه‌دهنده اینترنت (ISP)", blank=True, null=True)
viewd = models.BooleanField(default=False, verbose_name='تماشا شده')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='شروع حمله')
trys = models.IntegerField(default=0, verbose_name='تعداد تلاش ها')
def save(self, *args, **kwargs):
if not self.id:
location_data = get_location_from_ip(self.ip_address)
if location_data:
self.country, self.region_name, self.city, self.zip_code, self.lat, self.lon, self.isp = location_data
super().save(*args, **kwargs)
def __str__(self):
return f'تلاش نفوذ از {self.ip_address} در {self.city}, {self.country}'
class Meta:
verbose_name = "تلاش نفوذ"
verbose_name_plural = "تلاش‌های نفوذ"
+2 -1
View File
@@ -12,5 +12,6 @@ urlpatterns = [
path('address/delete/<int:pk>', views.DeleteAddressView.as_view(), name='delete-address'), path('address/delete/<int:pk>', views.DeleteAddressView.as_view(), name='delete-address'),
path('address/list', views.GetUserAddressesView.as_view(), name='list-addresses'), path('address/list', views.GetUserAddressesView.as_view(), name='list-addresses'),
path('address/<int:pk>', views.GetIDUserAddressView.as_view(), name='get-ID-address'), path('address/<int:pk>', views.GetIDUserAddressView.as_view(), name='get-ID-address'),
path('subscribe', views.SubscribeView.as_view(), name='subscibe') path('subscribe', views.SubscribeView.as_view(), name='subscibe'),
path('attack/view/<int:pk>', views.ChangeViewAttack.as_view(), name='attack-view'),
] ]
+15 -5
View File
@@ -3,13 +3,14 @@ from rest_framework.views import APIView
from rest_framework import generics, permissions, status from rest_framework import generics, permissions, status
from rest_framework.response import Response from rest_framework.response import Response
from .serializers import * from .serializers import *
from .models import UserAddressModel, User from .models import UserAddressModel, User, SecurityBreachAttemptModel
from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework.permissions import IsAuthenticated, AllowAny
from drf_spectacular.utils import extend_schema, OpenApiParameter from drf_spectacular.utils import extend_schema, OpenApiParameter
from rest_framework_simplejwt.views import TokenObtainPairView from rest_framework_simplejwt.views import TokenObtainPairView
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404, redirect
from rest_framework_simplejwt.tokens import RefreshToken from rest_framework_simplejwt.tokens import RefreshToken
import ghasedak_sms import ghasedak_sms
from django.views import View
# this works only need to be used # this works only need to be used
# class APIView(APIView): # class APIView(APIView):
# def __init__(self, *args, **kwargs): # def __init__(self, *args, **kwargs):
@@ -52,9 +53,9 @@ class SendOTPView(APIView):
ghasedak_sms.SendSingleSmsInput( ghasedak_sms.SendSingleSmsInput(
message=message, message=message,
receptor=phone, receptor=phone,
line_number='90002930', line_number='50001212124889',
send_date='', send_date='',
client_reference_id='' client_reference_id=str(user.pk)
) )
) )
@@ -64,6 +65,7 @@ class SendOTPView(APIView):
return Response({'detail': 'OTP sent successfully'}, status=status.HTTP_200_OK) return Response({'detail': 'OTP sent successfully'}, status=status.HTTP_200_OK)
else: else:
print('remmber to remove #TODO') print('remmber to remove #TODO')
print(response)
return Response({'detail': f'OTP sent successfully {otp}'}, status=status.HTTP_200_OK) return Response({'detail': f'OTP sent successfully {otp}'}, 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)
@@ -185,4 +187,12 @@ class SubscribeView(APIView):
defaults=(push_ser.validated_data) defaults=(push_ser.validated_data)
) )
return Response(status=status.HTTP_201_CREATED) return Response(status=status.HTTP_201_CREATED)
return Response(status=status.HTTP_400_BAD_REQUEST) return Response(status=status.HTTP_400_BAD_REQUEST)
class ChangeViewAttack(View):
def get(self, request, pk):
attack = get_object_or_404(SecurityBreachAttemptModel, pk=pk)
attack.viewd = not attack.viewd
attack.save()
return redirect('admin:account_securitybreachattemptmodel_changelist')
+6
View File
@@ -209,6 +209,12 @@ UNFOLD = {
"icon": "contact_mail", "icon": "contact_mail",
"link": reverse_lazy("admin:account_useraddressmodel_changelist"), "link": reverse_lazy("admin:account_useraddressmodel_changelist"),
}, },
{
"title": _("تلاش‌های نفوذ"),
"icon": "gpp_maybe",
"link": reverse_lazy("admin:account_securitybreachattemptmodel_changelist"),
"badge": 'utils.admin.new_attck_count'
},
], ],
}, },
+19 -12
View File
@@ -9,7 +9,7 @@ from django.views.generic import RedirectView, TemplateView
from unfold.views import UnfoldModelAdminViewMixin from unfold.views import UnfoldModelAdminViewMixin
from order.models import OrderModel from order.models import OrderModel
from ticket.models import Ticket from ticket.models import Ticket
from account.models import SecurityBreachAttemptModel
import json import json
@@ -267,20 +267,27 @@ class FakeAdminLoginView(View):
return context return context
def get(self, request): def get(self, request):
# Log empty attempt (optional) x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
ip = request.META.get("REMOTE_ADDR") if x_forwarded_for:
print(f"Honeypot page accessed from IP: {ip}") ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get("REMOTE_ADDR")
print(ip)
print(len(ip))
print(type(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))
def post(self, request): def post(self, request):
username = request.POST.get("username") x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
password = request.POST.get("password") # Never actually used if x_forwarded_for:
ip = request.META.get("REMOTE_ADDR") ip = x_forwarded_for.split(',')[0]
else:
print(f"Honeypot triggered! IP: {ip}, Username: {username}") ip = request.META.get("REMOTE_ADDR")
print(ip)
hacker, created = SecurityBreachAttemptModel.objects.get_or_create(ip_address=ip)
hacker.trys += 1
hacker.save()
messages.error(request, "Please correct the error below.") messages.error(request, "Please correct the error below.")
messages.error(request, "Please enter the correct شماره تماس and password for a staff account. Note that both fields may be case-sensitive.") messages.error(request, "Please enter the correct شماره تماس and password for a staff account. Note that both fields may be case-sensitive.")
# Redirect back to fake login page with context
return render(request, 'admin/fake_login.html', self.get_context(request)) return render(request, 'admin/fake_login.html', self.get_context(request))
+44 -28
View File
@@ -10,12 +10,6 @@ from unfold.widgets import UnfoldAdminColorInputWidget
from unfold.decorators import action, display from unfold.decorators import action, display
from utils.admin import ModelAdmin from utils.admin import ModelAdmin
@admin.register(ProductVariant)
class ProductVariantAdmin(ModelAdmin, ImportExportModelAdmin):
import_form_class = ImportForm
export_form_class = ExportForm
autocomplete_fields = ['product_attributes', 'images', 'in_pack_items']
warn_unsaved_form = True
@@ -48,6 +42,15 @@ class InPackItemsAdmin(ModelAdmin, ImportExportModelAdmin):
} }
class AttributeValueInLine(StackedInline):
model = AttributeValue
extra = 0
show_change_link = True
min_num = 1
# autocomplete_fields = ['product_attributes', 'in_pack_items', 'images']
# search_fields = ['']
@admin.register(AttributeType) @admin.register(AttributeType)
class AttributeTypeAdmin(ModelAdmin, ImportExportModelAdmin): class AttributeTypeAdmin(ModelAdmin, ImportExportModelAdmin):
import_form_class = ImportForm import_form_class = ImportForm
@@ -55,7 +58,7 @@ class AttributeTypeAdmin(ModelAdmin, ImportExportModelAdmin):
search_fields = ['name'] search_fields = ['name']
compressed_fields = True compressed_fields = True
warn_unsaved_form = True warn_unsaved_form = True
inlines = [AttributeValueInLine]
formfield_overrides = { formfield_overrides = {
ArrayField: { ArrayField: {
"widget": ArrayWidget, "widget": ArrayWidget,
@@ -98,20 +101,6 @@ class ProductImagesAdmin(ModelAdmin):
class ProductVariantInLine(StackedInline):
model = ProductVariant
extra = 0
show_change_link = True
tab = True
min_num = 1
autocomplete_fields = ['product_attributes', 'in_pack_items', 'images']
# search_fields = ['']
def formfield_for_dbfield(self, db_field, request, **kwargs):
if db_field.name == 'color':
kwargs['widget'] = UnfoldAdminColorInputWidget()
return super().formfield_for_dbfield(db_field, request, **kwargs)
@@ -129,24 +118,51 @@ class DetailModelAdmin(ModelAdmin, ImportExportModelAdmin):
"widget": ArrayWidget, "widget": ArrayWidget,
} }
} }
@admin.register(ProductDetailModel)
class ProductDetailModel1Admin(ModelAdmin, ImportExportModelAdmin):
import_form_class = ImportForm
export_form_class = ExportForm
search_fields = ['detail_category__title']
compressed_fields = True
warn_unsaved_form = True
formfield_overrides = {
ArrayField: {
"widget": ArrayWidget,
}
}
class ProductVariantInLine(StackedInline):
class DetailModelInLine(TabularInline): model = ProductVariant
model = ProductDetailModel
extra = 0 extra = 0
fields = ['detail', 'detail_category']
show_change_link = True show_change_link = True
autocomplete_fields = ['detail', 'detail_category'] tab = True
min_num = 1
# inlines = [DetailModelInLine]
autocomplete_fields = ['product_attributes', 'in_pack_items', 'images', 'details']
# search_fields = ['']
def formfield_for_dbfield(self, db_field, request, **kwargs):
if db_field.name == 'color':
kwargs['widget'] = UnfoldAdminColorInputWidget()
return super().formfield_for_dbfield(db_field, request, **kwargs)
@admin.register(ProductVariant)
class ProductVariantAdmin(ModelAdmin, ImportExportModelAdmin):
import_form_class = ImportForm
export_form_class = ExportForm
autocomplete_fields = ['product_attributes', 'images', 'in_pack_items', 'details']
warn_unsaved_form = True
# inlines = [DetailModelInLine]
@admin.register(ProductModel) @admin.register(ProductModel)
class ProductModelAdmin(ModelAdmin, ImportExportModelAdmin): class ProductModelAdmin(ModelAdmin, ImportExportModelAdmin):
import_form_class = ImportForm import_form_class = ImportForm
export_form_class = ExportForm export_form_class = ExportForm
inlines = [ProductVariantInLine, DetailModelInLine] inlines = [ProductVariantInLine]
readonly_fields = ('slug', ) readonly_fields = ('slug', )
search_fields = ['name', 'description', ] search_fields = ['name', 'description', ]
list_filter = ['show', 'category'] list_filter = ['show', 'category']
@@ -0,0 +1,19 @@
# Generated by Django 5.1.2 on 2025-02-18 19:43
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('product', '0030_rename_attributes_productvariant_product_attributes_and_more'),
]
operations = [
migrations.AlterField(
model_name='productdetailmodel',
name='product',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='details', to='product.productvariant', verbose_name='محصول مرتبط'),
),
]
@@ -0,0 +1,26 @@
# Generated by Django 5.1.2 on 2025-02-18 20:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('product', '0031_alter_productdetailmodel_product'),
]
operations = [
migrations.AlterUniqueTogether(
name='productdetailmodel',
unique_together=set(),
),
migrations.AddField(
model_name='productvariant',
name='details',
field=models.ManyToManyField(related_name='product', to='product.productdetailmodel', verbose_name='محصول مرتبط'),
),
migrations.RemoveField(
model_name='productdetailmodel',
name='product',
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.2 on 2025-02-18 20:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('product', '0032_alter_productdetailmodel_unique_together_and_more'),
]
operations = [
migrations.AlterField(
model_name='productvariant',
name='details',
field=models.ManyToManyField(related_name='product', to='product.productdetailmodel', verbose_name='جزيیات محصول'),
),
]
+11 -12
View File
@@ -152,17 +152,6 @@ class ProductDetailCategory(models.Model):
verbose_name = 'دسته بندی جزيات' verbose_name = 'دسته بندی جزيات'
verbose_name_plural = 'دسته بندی های جزيیات' verbose_name_plural = 'دسته بندی های جزيیات'
class ProductDetailModel(models.Model):
product = models.ForeignKey(ProductModel, on_delete=models.CASCADE, verbose_name='محصول مرتبط', related_name='details')
detail_category = models.ForeignKey(ProductDetailCategory, on_delete=models.CASCADE, verbose_name='دسته بندی جزيات', blank=True, null=True)
detail = models.ManyToManyField(DetailModel, verbose_name='جزيات ها')
class Meta:
verbose_name = 'جزیات محصول'
verbose_name_plural = 'جزیات محصول ها'
unique_together = ('product', 'detail_category')
def __str__(self):
return f'جزيیات محصول {self.product}'
@@ -216,6 +205,15 @@ class ProductImageModel(models.Model):
verbose_name_plural = 'عکس های محصولات' verbose_name_plural = 'عکس های محصولات'
class ProductDetailModel(models.Model):
detail_category = models.ForeignKey(ProductDetailCategory, on_delete=models.CASCADE, verbose_name='دسته بندی جزيات', blank=True, null=True)
detail = models.ManyToManyField(DetailModel, verbose_name='جزيات ها')
class Meta:
verbose_name = 'جزیات محصول'
verbose_name_plural = 'جزیات محصول ها'
# def __str__(self):
# return f'جزيیات محصول {self.product}'
class ProductVariant(models.Model): class ProductVariant(models.Model):
@@ -237,6 +235,7 @@ class ProductVariant(models.Model):
color = models.CharField(verbose_name='رنک', max_length=7, blank=True, null=True) color = models.CharField(verbose_name='رنک', max_length=7, blank=True, null=True)
images = models.ManyToManyField(ProductImageModel, verbose_name='عکس ها') images = models.ManyToManyField(ProductImageModel, verbose_name='عکس ها')
video = models.FileField(upload_to='product_videos/', blank=True, null=True, verbose_name='ویدیو') video = models.FileField(upload_to='product_videos/', blank=True, null=True, verbose_name='ویدیو')
details = models.ManyToManyField(ProductDetailModel, verbose_name='جزيیات محصول', related_name='product')
class Meta: class Meta:
verbose_name = 'تنوع محصول' verbose_name = 'تنوع محصول'
verbose_name_plural = 'تنوع‌های محصول' verbose_name_plural = 'تنوع‌های محصول'
@@ -262,4 +261,4 @@ class ProductVariant(models.Model):
return toman_price return toman_price
def get_toman_price_after_discount(self): def get_toman_price_after_discount(self):
return self.get_toman_price() * ((100 - self.discount) / 100) return self.get_toman_price() * ((100 - self.discount) / 100)
+33 -28
View File
@@ -14,9 +14,10 @@ class DetailSerializer(serializers.ModelSerializer):
class ProductDetailSerializer(serializers.ModelSerializer): class ProductDetailSerializer(serializers.ModelSerializer):
detail = DetailSerializer(many=True, read_only=True) detail = DetailSerializer(many=True, read_only=True)
detail_category = serializers.StringRelatedField()
class Meta: class Meta:
model = ProductDetailModel model = ProductDetailModel
exclude = ('product',) fields = "__all__"
class AttributeTypeSerialzier(serializers.ModelSerializer): class AttributeTypeSerialzier(serializers.ModelSerializer):
@@ -48,6 +49,7 @@ class ProductVariantSerialzier(serializers.ModelSerializer):
price = serializers.SerializerMethodField() price = serializers.SerializerMethodField()
in_pack_items = InPackItemsSerialzier(many=True) in_pack_items = InPackItemsSerialzier(many=True)
images = ProductImageSerailizer(many=True) images = ProductImageSerailizer(many=True)
details = ProductDetailSerializer(many=True, read_only=True)
class Meta: class Meta:
model = ProductVariant model = ProductVariant
exclude = ('min_price', 'max_price','sell', 'currency', 'product') exclude = ('min_price', 'max_price','sell', 'currency', 'product')
@@ -66,14 +68,31 @@ class ProductVariantSerialzier(serializers.ModelSerializer):
class SubCategorySerializer(serializers.ModelSerializer):
product_count = serializers.SerializerMethodField()
parent = serializers.SerializerMethodField()
class Meta:
model = SubCategoryModel
fields = ['id', 'name', 'slug','icon', 'meta_title', 'meta_description', 'product_count', 'show', 'parent', 'image']
def get_product_count(self, obj):
return obj.products.count()
def get_parent(self, obj):
return obj.parent.name
class MainCategorySerializer(serializers.ModelSerializer):
subcategorys = SubCategorySerializer(many=True)
class Meta:
model = MainCategoryModel
fields = ['id', 'name', 'slug', 'icon', 'meta_title', 'meta_description', 'subcategorys']
class DynamicProductSerializer(serializers.ModelSerializer): class DynamicProductSerializer(serializers.ModelSerializer):
variants = serializers.SerializerMethodField() variants = serializers.SerializerMethodField()
# variants_colors = serializers.SerializerMethodField() colors = serializers.SerializerMethodField()
category = SubCategorySerializer(read_only=True)
is_new = serializers.SerializerMethodField() is_new = serializers.SerializerMethodField()
related_products = serializers.SerializerMethodField() related_products = serializers.SerializerMethodField()
details = ProductDetailSerializer(many=True, read_only=True)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -91,18 +110,21 @@ class DynamicProductSerializer(serializers.ModelSerializer):
model = ProductModel model = ProductModel
fields = "__all__" fields = "__all__"
view_type = { view_type = {
'list': ['name', 'rating', 'slug', 'category', 'variants'], 'list': ['id','name', 'rating', 'slug', 'category', 'variants', 'colors'],
'instance': ['name', 'description', 'rating', 'slug', 'meta_description', 'meta_keywords', 'meta_rating', 'category', 'related_products', 'details', 'in_pack_items', 'variants'], 'instance': ['id', 'name', 'description', 'rating', 'slug', 'meta_description', 'meta_keywords', 'meta_rating', 'category', 'related_products', 'in_pack_items', 'variants', 'colors'],
'chat': ['name', 'description', 'variants'] 'chat': ['id', 'name', 'description', 'variants']
} }
def get_variants(self, obj): def get_variants(self, obj):
return ProductVariantSerialzier(instance=obj.variants.all(), many=True, context=self.context).data varients = obj.variants.all()
colors = set(varient.color for varient in varients)
return ProductVariantSerialzier(instance=varients, many=True, context=self.context).data
# def get_variants_colors(self, obj):
# varients = obj.variants.all() def get_colors(self, obj):
# attributes = AttributeValue.objects.filter(variant__in=varients) varients = obj.variants.all()
# return AttributeValueForProductListSerialzier(instance=attributes, many=True, context=self.context).data colors = list(set(varient.color for varient in varients))
return colors
def get_is_new(self, obj): def get_is_new(self, obj):
@@ -130,20 +152,3 @@ class CommentSerializer(serializers.ModelSerializer):
exclude = ('review_status', ) exclude = ('review_status', )
read_only_fields = ('review_status', 'product', 'user') read_only_fields = ('review_status', 'product', 'user')
class SubCategorySerializer(serializers.ModelSerializer):
product_count = serializers.SerializerMethodField()
parent = serializers.SerializerMethodField()
class Meta:
model = SubCategoryModel
fields = ['id', 'name', 'slug','icon', 'meta_title', 'meta_description', 'product_count', 'show', 'parent', 'image']
def get_product_count(self, obj):
return obj.products.count()
def get_parent(self, obj):
return obj.parent.name
class MainCategorySerializer(serializers.ModelSerializer):
subcategorys = SubCategorySerializer(many=True)
class Meta:
model = MainCategoryModel
fields = ['id', 'name', 'slug', 'icon', 'meta_title', 'meta_description', 'subcategorys']
+6
View File
@@ -5,6 +5,7 @@ annotated-types==0.7.0
anyio==4.6.0 anyio==4.6.0
asgiref==3.8.1 asgiref==3.8.1
attrs==24.2.0 attrs==24.2.0
branca==0.8.1
certifi==2024.8.30 certifi==2024.8.30
cffi==1.17.1 cffi==1.17.1
charset-normalizer==3.3.2 charset-normalizer==3.3.2
@@ -29,6 +30,7 @@ drf-spectacular==0.27.2
email_validator==2.2.0 email_validator==2.2.0
factory_boy==3.3.1 factory_boy==3.3.1
Faker==28.4.1 Faker==28.4.1
folium==0.19.4
frozenlist==1.4.1 frozenlist==1.4.1
geoip2==4.8.0 geoip2==4.8.0
ghasedak_sms==1.0.3 ghasedak_sms==1.0.3
@@ -43,11 +45,14 @@ idna==3.10
inflection==0.5.1 inflection==0.5.1
jalali_core==1.0.0 jalali_core==1.0.0
jdatetime==5.0.0 jdatetime==5.0.0
Jinja2==3.1.5
jiter==0.8.2 jiter==0.8.2
jsonschema==4.23.0 jsonschema==4.23.0
jsonschema-specifications==2024.10.1 jsonschema-specifications==2024.10.1
MarkupSafe==3.0.2
maxminddb==2.6.2 maxminddb==2.6.2
multidict==6.1.0 multidict==6.1.0
numpy==2.2.3
oauthlib==3.2.2 oauthlib==3.2.2
openai==1.58.1 openai==1.58.1
pillow==10.4.0 pillow==10.4.0
@@ -83,4 +88,5 @@ tzdata==2024.1
uritemplate==4.1.1 uritemplate==4.1.1
urllib3==2.2.3 urllib3==2.2.3
whitenoise==6.7.0 whitenoise==6.7.0
xyzservices==2025.1.0
yarl==1.11.1 yarl==1.11.1
+187
View File
@@ -0,0 +1,187 @@
{% extends "admin/base_site.html" %}
{% load unfold %}
{% load i18n admin_urls static admin_modify %}
{% block extrahead %}{{ block.super }}
<script src="{% url 'admin:jsi18n' %}"></script>
{{ media }}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.7.1/dist/leaflet.css" />
{% endblock %}
{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} change-form{% endblock %}
{% if not is_popup %}
{% block breadcrumbs %}
<div class="px-4 lg:px-8">
<div class="container mb-6 mx-auto -my-3 lg:mb-12">
<ul class="flex flex-wrap">
{% url 'admin:index' as link %}
{% trans 'Home' as name %}
{% include 'unfold/helpers/breadcrumb_item.html' with link=link name=name %}
{% url 'admin:app_list' app_label=opts.app_label as link %}
{% include 'unfold/helpers/breadcrumb_item.html' with link=link name=opts.app_config.verbose_name %}
{% if has_view_permission %}
{% url opts|admin_urlname:'changelist' as link %}
{% include 'unfold/helpers/breadcrumb_item.html' with link=link name=opts.verbose_name_plural|capfirst %}
{% else %}
{% include 'unfold/helpers/breadcrumb_item.html' with link='' name=opts.verbose_name_plural|capfirst %}
{% endif %}
{% if add %}
{% blocktranslate trimmed with name=opts.verbose_name asvar breadcrumb_name %}
Add {{ name }}
{% endblocktranslate %}
{% include 'unfold/helpers/breadcrumb_item.html' with link='' name=breadcrumb_name %}
{% else %}
{% include 'unfold/helpers/breadcrumb_item.html' with link='' name=original|truncatewords:'18' %}
{% endif %}
</ul>
</div>
</div>
{% endblock %}
{% endif %}
{% block nav-global-side %}
{% if has_add_permission %}
{% include "unfold/helpers/add_link.html" %}
{% endif %}
{% endblock %}
{% block content %}
<div id="content-main" class="flex flex-col gap-4">
{% block form_before %}{% endblock %}
{% if adminform.model_admin.change_form_outer_before_template %}
{% include adminform.model_admin.change_form_outer_before_template %}
{% endif %}
<div class="border border-base-200 border-dashed mb-4 p-3 rounded dark:border-base-700 gap-1.5">
{% if original.lon and original.lat %}
<div class="location-details" style="margin-bottom: 0rem; padding: 0.5rem 1rem; background-color: rgba(var(--color-primary-100), 0.3); border-radius: var(--border-radius,6px);">
<div style="display: flex; flex-wrap: wrap; gap: 0.75rem; justify-content: center; align-items: center; font-size: 0.9em; color: rgb(var(--color-primary-500));" dir="rtl">
{% if original.country %}
<span class="badge" style="padding: 0.25rem 0.75rem; background-color: rgb(var(--color-primary-500)); color: white; border-radius: 20px;">
🇺🇳 {{ original.country }}
</span>
{% endif %}
{% if original.region_name %}
<span style="display: inline-flex; align-items: center;">
<i class="fas fa-mountain mr-1"></i>
{{ original.region_name }}
</span>
{% endif %}
{% if original.city %}
<span style="display: inline-flex; align-items: center;">
<i class="fas fa-city mr-1"></i>
{{ original.city }}
</span>
{% endif %}
{% if original.zip_code %}
<span style="display: inline-flex; align-items: center;">
<i class="fas fa-mail-bulk mr-1"></i>
{{ original.zip_code }}
</span>
{% endif %}
{% if original.isp %}
<span style="display: inline-flex; align-items: center;">
<i class="fas fa-network-wired mr-1"></i>
{{ original.isp }}
</span>
{% endif %}
{% if original.ip_address %}
<span style="display: inline-flex; align-items: center;">
<i class="fas fa-network-wired mr-1"></i>
{{ original.ip_address }}
</span>
{% endif %}
</div>
</div>
{% if map_html %}
<!-- Render the Folium map with a fixed height -->
<div style="display: flex;justify-content: center;align-items: center;width: 100%;padding: 25px 0px;">
<div style="border-radius: var(--border-radius,6px);width: 100%;max-width: 800px;">
{{ map_html|safe }}
</div>
</div>
{% else %}
<p>نقشه در دسترس نیست</p>
{% endif %}
{% else %}
<p>موقعیت جغرافیایی موجود نیست</p>
{% endif %}
</div>
<!--
<form {% if has_file_field %}enctype="multipart/form-data" {% endif %}{% if form_url %}action="{{ form_url }}" {% endif %}method="post" id="{{ opts.model_name }}_form" {% if adminform.model_admin.warn_unsaved_form %}class="warn-unsaved-form"{% endif %} novalidate>
{% csrf_token %}
{% if adminform.model_admin.change_form_before_template %}
{% include adminform.model_admin.change_form_before_template %}
{% endif %}
{% block form_top %}{% endblock %}
<div>
{% if is_popup %}
<input type="hidden" name="{{ is_popup_var }}" value="1">
{% endif %}
{% if to_field %}
<input type="hidden" name="{{ to_field_var }}" value="{{ to_field }}">
{% endif %}
{% include "unfold/helpers/messages/errornote.html" with errors=errors %}
{% include "unfold/helpers/messages/error.html" with errors=adminform.form.non_field_errors %}
{% block field_sets %}
{% for fieldset in adminform %}
{% if "tab" not in fieldset.classes %}
{% include 'admin/includes/fieldset.html' %}
{% endif %}
{% endfor %}
{% include "unfold/helpers/fieldsets_tabs.html" %}
{% endblock %}
{% block after_field_sets %}{% endblock %}
{% block inline_field_sets %}
{% for inline_admin_formset in inline_admin_formsets %}
{% include inline_admin_formset.opts.template %}
{% endfor %}
{% endblock %}
{% block after_related_objects %}{% endblock %}
{% if adminform.model_admin.change_form_after_template %}
{% include adminform.model_admin.change_form_after_template %}
{% endif %}
{% block submit_buttons_bottom %}{% submit_row %}{% endblock %}
{% block admin_change_form_document_ready %}
<script id="django-admin-form-add-constants" src="{% static 'admin/js/change_form.js' %}"{% if adminform and add %} data-model-name="{{ opts.model_name }}"{% endif %} async></script>
{% endblock %}
{% prepopulated_fields_js %}
</div>
</form> -->
{% if adminform.model_admin.change_form_outer_after_template %}
{% include adminform.model_admin.change_form_outer_after_template %}
{% endif %}
{% block form_after %}{% endblock %}
</div>
{% endblock %}
{% block extrajs %}
<!-- Add Leaflet JS for Folium -->
<script src="https://unpkg.com/leaflet@1.7.1/dist/leaflet.js"></script>
{% endblock %}
@@ -0,0 +1,19 @@
# Generated by Django 5.1.2 on 2025-02-21 17:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ticket', '0010_alter_message_created_at_alter_ticket_created_at_and_more'),
]
operations = [
migrations.AddField(
model_name='ticket',
name='content',
field=models.TextField(default='', verbose_name='جزيیات تیکت'),
preserve_default=False,
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.2 on 2025-02-21 18:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ticket', '0011_ticket_content'),
]
operations = [
migrations.AlterField(
model_name='ticket',
name='status',
field=models.CharField(choices=[('in_progress', 'در حال پردازش'), ('resolved', 'حل شده'), ('closed', 'بسته')], default='open', max_length=20, verbose_name='وضعیت تیکت'),
),
]
+1 -1
View File
@@ -6,7 +6,6 @@ from django_jalali.db import models as jmodels
class Ticket(models.Model): class Ticket(models.Model):
objects = jmodels.jManager() objects = jmodels.jManager()
STATUS_CHOICES = [ STATUS_CHOICES = [
('open', 'باز'),
('in_progress', 'در حال پردازش'), ('in_progress', 'در حال پردازش'),
('resolved', 'حل شده'), ('resolved', 'حل شده'),
('closed', 'بسته'), ('closed', 'بسته'),
@@ -21,6 +20,7 @@ class Ticket(models.Model):
('other', 'سایر'), ('other', 'سایر'),
] ]
subject = models.CharField(max_length=255, verbose_name='موضوع') subject = models.CharField(max_length=255, verbose_name='موضوع')
content = models.TextField(verbose_name='جزيیات تیکت')
ticket_category = models.CharField(max_length=30, verbose_name='دسته بندی تیکت', choices=CATEGORY_CHOICES) ticket_category = models.CharField(max_length=30, verbose_name='دسته بندی تیکت', choices=CATEGORY_CHOICES)
customer = models.ForeignKey(User, on_delete=models.CASCADE, related_name="tickets", verbose_name='کاربر') customer = models.ForeignKey(User, on_delete=models.CASCADE, related_name="tickets", verbose_name='کاربر')
admin = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_tickets", verbose_name='ادمین') admin = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_tickets", verbose_name='ادمین')
+8 -1
View File
@@ -13,4 +13,11 @@ class TicketSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Ticket model = Ticket
exclude = ('customer', ) exclude = ('customer', )
read_only_fields = ('status', 'admin', ) read_only_fields = ('status', 'admin', )
class TicketListSerializer(serializers.ModelSerializer):
class Meta:
model = Ticket
exclude = ('customer', 'admin', 'order', 'content')
read_only_fields = ('status',)
+1 -2
View File
@@ -11,6 +11,5 @@ urlpatterns = [
path('create', TicketCreateView.as_view(), name='ticket-create'), path('create', TicketCreateView.as_view(), name='ticket-create'),
path('', TicketListView.as_view(), name='ticket-list'), path('', TicketListView.as_view(), name='ticket-list'),
path('<int:pk>', TicketDetailView.as_view(), name='ticket-detail'), path('<int:pk>', TicketDetailView.as_view(), name='ticket-detail'),
path('<int:pk>/messages', MessageCreateView.as_view(), name='message-create'), path('message/<int:pk>', MessageCreateView.as_view(), name='message-create'),
path('<int:pk>/update-status', UpdateTicketStatusView.as_view(), name='update-ticket-status'),
] ]
+60 -9
View File
@@ -2,7 +2,9 @@ from rest_framework import generics, permissions
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from .models import Ticket, Message from .models import Ticket, Message
from .serializers import TicketSerializer, MessageSerializer from .serializers import TicketListSerializer, MessageSerializer, TicketSerializer
from utils.pagination import StructurePagination
from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes
class TicketCreateView(generics.CreateAPIView): class TicketCreateView(generics.CreateAPIView):
queryset = Ticket.objects.all() queryset = Ticket.objects.all()
@@ -12,16 +14,65 @@ class TicketCreateView(generics.CreateAPIView):
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(customer=self.request.user) serializer.save(customer=self.request.user)
class TicketListView(generics.ListAPIView): class TicketListView(APIView):
serializer_class = TicketSerializer serializer_class = TicketListSerializer
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
pagination_class = StructurePagination
def get_queryset(self): @extend_schema(
user = self.request.user parameters=[
if user.is_staff: OpenApiParameter(
return Ticket.objects.all() name="limit",
return Ticket.objects.filter(customer=user) description="لیمیتش",
required=False,
type=OpenApiTypes.INT,
),
OpenApiParameter(
name="offset",
description="افستش",
required=False,
type=OpenApiTypes.INT,
),
OpenApiParameter(
name="filter",
description=(
"filter results by one of the following fields:\n"
"`in_progress`, `closed`, `resolved`."
),
required=False,
type=OpenApiTypes.STR,
),
OpenApiParameter(
name="sort",
description=(
"Sort results by one of the following fields:\n"
" `created_at`, `-created_at`."
"\nPrefix with `-` for descending order."
),
required=False,
type=OpenApiTypes.STR,
),
],
responses={
200: TicketListSerializer(many=True),
404: OpenApiTypes.OBJECT,
},
)
def get(self, request):
tickets = Ticket.objects.filter(customer=request.user)
filter_by = request.query_params.get('filter', None)
sort = request.query_params.get('sort', None)
if filter_by:
tickets.filter(status=str(filter_by))
if sort:
if sort not in ['created_at', '-created_at']:
return Response({'detail': 'wrong sort paramter'}, status=status.HTTP_400_BAD_REQUEST)
tickets.order_by(sort)
paginator = self.pagination_class()
paginated_tickets = paginator.paginate_queryset(tickets, request)
tickets_ser = self.serializer_class(instance=paginated_tickets, many=True, context={'request': request})
return paginator.get_paginated_response(tickets_ser.data)
class TicketDetailView(generics.RetrieveAPIView): class TicketDetailView(generics.RetrieveAPIView):
+3
View File
@@ -2,6 +2,7 @@ from order.models import OrderModel
from product.models import DollorModel, CommentModel from product.models import DollorModel, CommentModel
from ticket.models import Ticket from ticket.models import Ticket
from home.models import LearnVideoModel from home.models import LearnVideoModel
from account.models import SecurityBreachAttemptModel
def admin_pending_count(request): def admin_pending_count(request):
pending_count = OrderModel.objects.filter(status='ADMIN_PENDING').count() pending_count = OrderModel.objects.filter(status='ADMIN_PENDING').count()
@@ -20,6 +21,8 @@ def new_ticket_count(request):
def new_learn_video_count(request): def new_learn_video_count(request):
return LearnVideoModel.objects.filter(viewd=False).count() return LearnVideoModel.objects.filter(viewd=False).count()
def new_attck_count(request):
return SecurityBreachAttemptModel.objects.filter(viewd=False).count()
from django.contrib import admin, messages from django.contrib import admin, messages
from unfold.admin import ModelAdmin from unfold.admin import ModelAdmin
+6 -4
View File
@@ -4,21 +4,23 @@ import { VueQueryDevtools } from "@tanstack/vue-query-devtools";
<template> <template>
<div> <div>
<LoadingIndicator />
<NuxtRouteAnnouncer /> <NuxtRouteAnnouncer />
<NuxtLayout> <NuxtLayout>
<LoadingIndicator />
<ToastProvider> <ToastProvider>
<NuxtPage /> <NuxtPage />
<div dir="ltr">
<VueQueryDevtools dir="ltr" buttonPosition="bottom-left" />
</div>
<ToastContainer /> <ToastContainer />
<ToastViewport <ToastViewport
class="[--viewport-padding:_25px] fixed bottom-0 left-0 flex flex-col p-[var(--viewport-padding)] gap-[10px] w-[390px] max-w-[100vw] m-0 list-none z-[2147483647] outline-none" class="[--viewport-padding:_25px] fixed bottom-0 left-0 flex flex-col p-[var(--viewport-padding)] gap-[10px] w-[390px] max-w-[100vw] m-0 list-none z-[2147483647] outline-none"
/> />
</ToastProvider> </ToastProvider>
</NuxtLayout> </NuxtLayout>
<VueQueryDevtools dir="ltr" buttonPosition="bottom-left" />
</div> </div>
</template> </template>
+1 -1
View File
@@ -12,7 +12,7 @@ const {} = toRefs(props);
</script> </script>
<template> <template>
<div class="relative w-full flex flex-col justify-center h-[350px]"> <div class="relative w-full flex flex-col justify-center min-h-[700px] h-[80svh]">
<div class="-rotate-z-2 z-20"> <div class="-rotate-z-2 z-20">
<div <div
class="bg-warning-500 flex pr-20 gap-20 py-2 w-max animate-marquee-reverse" class="bg-warning-500 flex pr-20 gap-20 py-2 w-max animate-marquee-reverse"
+3 -3
View File
@@ -3,7 +3,7 @@
// types // types
type Props = { type Props = {
selected ?: boolean; selected?: boolean;
} }
// props // props
@@ -14,7 +14,7 @@ defineProps<Props>();
<template> <template>
<div <div
class="size-[25px] rounded-full shadow-black/30 shadow-inner" class="size-[25px] rounded-full transition-all ring-2 ring-offset-4 shadow-black/30 shadow-inner"
:class="selected ? 'ring-black' : 'ring-transparent'" :class="selected ? 'ring-blue-500' : 'ring-transparent'"
/> />
</template> </template>
@@ -21,7 +21,7 @@ nuxtApp.hook("page:finish", () => {
<Transition name="fade"> <Transition name="fade">
<div <div
v-if="isLoading" v-if="isLoading"
class="h-[20px] flex items-center justify-center bg-black w-full left-0 top-0 fixed z-100" class="h-[20px] flex items-center justify-center bg-black w-full left-0 top-0 fixed z-9999"
> >
<div class="absolute progress-indicator w-1/3 bg-white h-1 rounded-full"></div> <div class="absolute progress-indicator w-1/3 bg-white h-1 rounded-full"></div>
</div> </div>
@@ -0,0 +1,88 @@
<script lang="ts" setup>
// state
const { $gsap: gsap } = useNuxtApp();
// lifecycle
onMounted(() => {
const timeline = gsap.timeline();
timeline
.to("#loading-overlay", {
scale: 1
})
.to("#loading-overlay", {
scale: 0.8,
opacity: 0,
delay: 5
})
.to("#loading-overlay", {
opacity: 0,
y: "20%"
});
});
</script>
<template>
<div id="loading-overlay" class="fixed inset-0 size-full z-9999 flex-center bg-black">
<img id="loading-overlay-image" src="/video/loading-2.gif" class="opacity-0 scale-70 absolute z-20" alt="" />
<div
id="loading-overlay-gradient"
class="opacity-0 scale-x-0 w-[1000px] h-[70px] bg-linear-to-r from-blue-500 via-violet-500 to-purple-500 blur-[150px] rounded-[100px]"
/>
</div>
</template>
<style>
#loading-overlay-image {
animation-name: loading-overlay-image-animation;
animation-duration: 1s;
animation-delay: 0.75s;
animation-fill-mode: forwards;
}
#loading-overlay-gradient {
animation: 1.5s normal 0.5s 1 forwards loading-overlay-gradient-animation,
1s ease-in-out 2s infinite alternate-reverse loading-overlay-gradient-pules-animation;
}
@keyframes loading-overlay-image-animation {
from {
opacity: 0;
scale: 0.7;
}
to {
opacity: 1;
scale: 1;
}
}
@keyframes loading-overlay-gradient-animation {
from {
opacity: 0;
scale: 0 1 1;
}
to {
opacity: 0.9;
scale: 1 1 1;
}
}
@keyframes loading-overlay-gradient-pules-animation {
from {
opacity: 0.8;
scale: 0.8 1 1;
}
to {
opacity: 0.9;
scale: 1 1 1;
}
}
</style>
@@ -91,9 +91,9 @@ const onSwiper = (swiper: SwiperClass) => {
:id="product.id" :id="product.id"
brand="برند محصول" brand="برند محصول"
:title="product.name" :title="product.name"
:picture="product.image1" :picture="product.variants[0].images[0].image"
:colors="['white', 'black']" :colors="product.variants.map(v => v.color)"
:price="product.price" :price="product.variants[0].price"
:rate="product.rating" :rate="product.rating"
:dark-layer="true" :dark-layer="true"
/> />
@@ -10,49 +10,58 @@ type Props = {
// props // props
const props = defineProps<Props>(); const props = defineProps<Props>();
const { modelValue } = toRefs(props); const { modelValue, max } = toRefs(props);
// state
const timer = ref<NodeJS.Timeout | null>(null);
// emit // emit
const emit = defineEmits(["update:modelValue"]); const emit = defineEmits(["update:modelValue"]);
// state // computed
const currentQuantity = ref(modelValue.value); const currentQuantity = computed({
get: () => modelValue.value ?? 0,
set: (value: number) => {
if (timer.value) clearTimeout(timer.value);
timer.value = setTimeout(() => {
emit("update:modelValue", value);
}, 50);
}
});
// methods // methods
const onInput = (e: any) => { const onInput = (e: any) => {
currentQuantity.value = Number(e.target.value); const value = Number(e.target.value);
if (value > 0 && value <= max.value) {
currentQuantity.value = value;
} else {
currentQuantity.value = 1;
}
}; };
// watch
watch(() => currentQuantity.value, (newValue) => {
emit("update:modelValue", newValue);
});
</script> </script>
<template> <template>
<div class=""> <NumberFieldRoot
<NumberFieldRoot class="rounded-full border-slate-200 border-[1.5px] flex items-center bg-white gap-4 p-4"
class="rounded-full border-slate-200 border-[1.5px] flex items-center bg-white gap-4 p-4" v-model="currentQuantity"
v-model="currentQuantity" :min="1"
:min="1" :max="max"
:max="max" >
> <NumberFieldIncrement class="cursor-pointer">
<NumberFieldIncrement class="cursor-pointer"> <Icon name="ci:plus" class="**:stroke-slate-500 size-5" />
<Icon name="ci:plus" class="**:stroke-slate-500 size-5" /> </NumberFieldIncrement>
</NumberFieldIncrement> <NumberFieldInput
<NumberFieldInput @input="onInput"
@input="onInput" class="field-sizing-content bg-transparent outline-none typo-label-md text-black"
class="field-sizing-content bg-transparent outline-none typo-label-md text-black" />
/> <NumberFieldDecrement class="cursor-pointer">
<NumberFieldDecrement class="cursor-pointer"> <Icon name="ci:minus" class="**:stroke-slate-500 size-5" />
<Icon name="ci:minus" class="**:stroke-slate-500 size-5" /> </NumberFieldDecrement>
</NumberFieldDecrement> </NumberFieldRoot>
</NumberFieldRoot>
</div>
</template> </template>
@@ -15,12 +15,12 @@ defineProps<Props>();
<template> <template>
<div class="flex flex-col gap-2 w-full"> <div class="flex flex-col gap-2 w-full">
<p class="typo-p-sm text-slate-500"> <p class="typo-p-md text-slate-500">
سریع باش فقط تعداد
<span class="text-black"> <span class="text-black font-bold">
{{ maxQuantity }} {{ maxQuantity }}
</span> </span>
عدد از این محصول باقی مانده عدد از این محصول موجود است
</p> </p>
<div class="h-2 rounded-full relative bg-slate-200 w-full"> <div class="h-2 rounded-full relative bg-slate-200 w-full">
<div <div
@@ -4,10 +4,7 @@
type Props = { type Props = {
selectedSlide: number; selectedSlide: number;
slides: { slides: ProductImage[]
id: number;
picture: string;
}[]
} }
// props // props
@@ -41,8 +38,8 @@ const changeSlide = (id: number) => {
<img <img
:key="selectedSlideDetail.id" :key="selectedSlideDetail.id"
class="size-full absolute object-contain" class="size-full absolute object-contain"
:src="selectedSlideDetail.picture" :src="selectedSlideDetail.image"
:alt="String(selectedSlideDetail.id)" :alt="selectedSlideDetail.name"
/> />
</Transition> </Transition>
</div> </div>
@@ -56,7 +53,7 @@ const changeSlide = (id: number) => {
> >
<img <img
class="absolute object-cover size-full" class="absolute object-cover size-full"
:src="slide.picture" :src="slide.image"
:alt="String(slide.id)" :alt="String(slide.id)"
/> />
</div> </div>
@@ -1,16 +1,30 @@
<script lang="ts" setup>
// provide / inject
import type { ProductVariantProvideType } from "~/pages/product/[id].vue";
const { selectedVariant } = inject("productVariant") as ProductVariantProvideType;
</script>
<template> <template>
<div class="w-full flex flex-col"> <div class="w-full flex flex-col">
<AccordionRoot <AccordionRoot
class="w-full last:border-b last:border-slate-200" class="w-full last:border-b last:border-slate-200"
default-value="item-1" :default-value="'item' + selectedVariant.details[0].detail_category"
type="single" type="single"
:collapsible="true" :collapsible="true"
> >
<AccordionItem value="item-1" class="overflow-hidden"> <AccordionItem
v-for="detailItem in selectedVariant.details"
:value="'item' + detailItem.detail_category"
class="overflow-hidden"
>
<AccordionHeader <AccordionHeader
class="border-t border-slate-200 py-[1.5rem] flex justify-between items-center" class="border-t border-slate-200 py-[1.5rem] flex justify-between items-center"
> >
<span class="typo-sub-h-md text-black">مشخصات</span> <span class="typo-sub-h-md text-black">{{ detailItem.detail_category }}</span>
<AccordionTrigger class="group"> <AccordionTrigger class="group">
<Icon <Icon
name="ci:plus" name="ci:plus"
@@ -26,97 +40,20 @@
class="w-full grid grid-cols-2 gap-y-[1.5rem] gap-x-[1rem]" class="w-full grid grid-cols-2 gap-y-[1.5rem] gap-x-[1rem]"
> >
<div <div
v-for="i in 4" v-for="item in detailItem.detail"
class="flex flex-col gap-y-[1.5rem]" class="flex flex-col gap-y-[1.5rem]"
> >
<span <span
class="typo-sub-h-lg text-black w-full pt-[1.5rem]" class="typo-sub-h-lg text-black w-full pt-[1.5rem]"
>صفحه نمایش</span
> >
{{ item.title }}
</span>
<ul class="list-disc w-full ps-5"> <ul class="list-disc w-full ps-5">
<li class="text-slate-500 typo-p-md"> <li
روشنایی :3000mn v-for="detail in [ item.detail_text1, item.detail_text2, item.detail_text3, item.detail_text4 ]"
</li> class="text-slate-500 typo-p-md"
<li class="text-slate-500 typo-p-md"> >
روشنایی :3000mn {{ detail }}
</li>
</ul>
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2" class="overflow-hidden">
<AccordionHeader
class="border-t border-slate-200 py-[1.5rem] flex justify-between items-center"
>
<span class="typo-sub-h-md text-black">مشخصات</span>
<AccordionTrigger class="group">
<Icon
name="ci:plus"
size="24"
class="group-data-[state=open]:rotate-45 transition-transform"
/>
</AccordionTrigger>
</AccordionHeader>
<AccordionContent
class="data-[state=open]:animate-slide-down pb-[1.5rem] data-[state=closed]:animate-slide-up overflow-hidden"
>
<div
class="w-full grid grid-cols-2 gap-y-[1.5rem] gap-x-[1rem]"
>
<div
v-for="i in 4"
class="flex flex-col gap-y-[1.5rem]"
>
<span
class="typo-sub-h-lg text-black w-full pt-[1.5rem]"
>صفحه نمایش</span
>
<ul class="list-disc w-full ps-5">
<li class="text-slate-500 typo-p-md">
روشنایی :3000mn
</li>
<li class="text-slate-500 typo-p-md">
روشنایی :3000mn
</li>
</ul>
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3" class="overflow-hidden">
<AccordionHeader
class="border-t border-slate-200 py-[1.5rem] flex justify-between items-center"
>
<span class="typo-sub-h-md text-black">مشخصات</span>
<AccordionTrigger class="group">
<Icon
name="ci:plus"
size="24"
class="group-data-[state=open]:rotate-45 transition-transform"
/>
</AccordionTrigger>
</AccordionHeader>
<AccordionContent
class="data-[state=open]:animate-slide-down pb-[1.5rem] data-[state=closed]:animate-slide-up overflow-hidden"
>
<div
class="w-full grid grid-cols-2 gap-y-[1.5rem] gap-x-[1rem]"
>
<div
v-for="i in 4"
class="flex flex-col gap-y-[1.5rem]"
>
<span
class="typo-sub-h-lg text-black w-full pt-[1.5rem]"
>صفحه نمایش</span
>
<ul class="list-disc w-full ps-5">
<li class="text-slate-500 typo-p-md">
روشنایی :3000mn
</li>
<li class="text-slate-500 typo-p-md">
روشنایی :3000mn
</li> </li>
</ul> </ul>
</div> </div>
@@ -125,8 +62,4 @@
</AccordionItem> </AccordionItem>
</AccordionRoot> </AccordionRoot>
</div> </div>
</template> </template>
<script setup lang="ts"></script>
<style scoped></style>
@@ -22,9 +22,12 @@ const { picture, price, title, color } = toRefs(props);
<div class="relative size-[100px] rounded-100 overflow-hidden border-[0.5px] border-slate-200"> <div class="relative size-[100px] rounded-100 overflow-hidden border-[0.5px] border-slate-200">
<img :src="picture" :alt="title" class="object-cover absolute" /> <img :src="picture" :alt="title" class="object-cover absolute" />
</div> </div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1.5">
<span class="typo-sub-h-md text-black">{{ title }}</span> <span class="typo-sub-h-md text-black">{{ title }}</span>
<span class="typo-p-sm text-slate-500">{{ color }}</span> <div class="flex items-center gap-2">
<span class="typo-p-sm text-slate-500">رنگ</span>
<ColorCircle class="!size-5" :style="{backgroundColor: color}" />
</div>
<span class="typo-p-md text-black">{{ price }}</span> <span class="typo-p-md text-black">{{ price }}</span>
</div> </div>
</div> </div>
-30
View File
@@ -8,15 +8,7 @@ import useHomeData from "~/composables/api/home/useHomeData";
// state // state
const { $gsap: gsap } = useNuxtApp();
const { data: homeData } = useHomeData(); const { data: homeData } = useHomeData();
const sectionTarget = ref(null);
const isSectionInsideViewport = useElementVisibility(sectionTarget, {
rootMargin: "0% 0px -100% 0px"
});
const swiper_instance = ref<SwiperClass | null>(null); const swiper_instance = ref<SwiperClass | null>(null);
// methods // methods
@@ -25,28 +17,6 @@ const onSwiper = (swiper: SwiperClass) => {
swiper_instance.value = swiper; swiper_instance.value = swiper;
}; };
// watch
watch(() => isSectionInsideViewport.value, (newValue) => {
if (newValue) {
gsap.fromTo("#header-navbar", {
background: "white",
filter: "invert(0%)"
}, {
background: "transparent",
filter: "invert(100%)"
});
} else {
gsap.fromTo("#header-navbar", {
background: "transparent",
filter: "invert(100%)"
}, {
background: "white",
filter: "invert(0%)"
});
}
});
</script> </script>
<template> <template>
+7 -3
View File
@@ -59,6 +59,11 @@ onMounted(() => {
gsapTimeline = gsap.timeline(); gsapTimeline = gsap.timeline();
gsap.to("#header-navbar", {
background: "transparent",
filter: "invert(100%)"
});
gsapTimeline gsapTimeline
.fromTo(".header-slider-item", { .fromTo(".header-slider-item", {
borderRadius: 0, borderRadius: 0,
@@ -73,8 +78,10 @@ onMounted(() => {
value: 1.2 value: 1.2
}, "=") }, "=")
.fromTo("#header-navbar", { .fromTo("#header-navbar", {
background: "transparent",
filter: "invert(100%)" filter: "invert(100%)"
}, { }, {
background: "transparent",
filter: "invert(0%)" filter: "invert(0%)"
}, "=") }, "=")
.fromTo("#header-navbar", { .fromTo("#header-navbar", {
@@ -99,9 +106,6 @@ onMounted(() => {
}, { }, {
padding: "0px 40px" padding: "0px 40px"
}, "=") }, "=")
.to(".header-slider-logo", {
opacity: 0
}, "-=150%");
ScrollTrigger.create({ ScrollTrigger.create({
trigger: "#header-slider-container", trigger: "#header-slider-container",
+44 -11
View File
@@ -10,16 +10,28 @@ const { data: homeData } = useHomeData();
const clipPathPercent = ref(49); const clipPathPercent = ref(49);
const activeSlideVideo = ref<"left" | "right" | "none">("none");
const draggableEl = ref<HTMLElement | null>(null); const draggableEl = ref<HTMLElement | null>(null);
const previewContainerEl = ref<HTMLElement | null>(null); const previewContainerEl = ref<HTMLElement | null>(null);
const { x: dragAxisX } = useDraggable(draggableEl, { const { x: dragAxisX } = useDraggable(draggableEl, {
initialValue: { x: 0, y: 0 }, initialValue: { x: 0, y: 0 },
axis: "x", axis: "x"
}); });
// watch // watch
watch(() => clipPathPercent.value, (newValue) => {
if (newValue > 80) {
activeSlideVideo.value = "right";
} else if (newValue < 20) {
activeSlideVideo.value = "left";
} else {
activeSlideVideo.value = "none";
}
});
watch( watch(
() => dragAxisX.value, () => dragAxisX.value,
(newValue) => { (newValue) => {
@@ -39,24 +51,45 @@ watch(
<span class="typo-p-md text-slate-500">مقایسه محصولات</span> <span class="typo-p-md text-slate-500">مقایسه محصولات</span>
<span class="typo-h-3 text-black"> <span class="typo-h-3 text-black">
تفاوت محصلات ما را ببینید تفاوت محصلات ما را ببینید
{{ activeSlideVideo }}
</span> </span>
</div> </div>
<div <div
ref="previewContainerEl" ref="previewContainerEl"
class="rounded-200 overflow-hidden h-[90svh] relative" class="rounded-200 overflow-hidden h-[90svh] relative"
> >
<img <Transition name="fade">
:src="homeData!.difreance_section.image1" <img
class="select-none absolute size-full object-cover brightness-[95%]" v-if="activeSlideVideo !== 'right'"
:alt="homeData!.difreance_section.title1" :src="homeData!.difreance_section.image1"
/> class="select-none absolute size-full object-cover brightness-[95%]"
:alt="homeData!.difreance_section.title1"
/>
<video
v-else
autoplay
muted
src="/video/vid-3.mp4"
class="select-none absolute size-full object-cover brightness-[95%]"
/>
</Transition>
<div class="absolute size-full right-0 w-full"> <div class="absolute size-full right-0 w-full">
<img <Transition name="fade">
:src="homeData!.difreance_section.image2" <img
class="overlay-image select-none absolute object-cover size-full brightness-[95%]" v-if="activeSlideVideo !== 'left'"
:alt="homeData!.difreance_section.title2" :src="homeData!.difreance_section.image2"
/> class="overlay-image select-none absolute object-cover size-full brightness-[95%]"
:alt="homeData!.difreance_section.title2"
/>
<video
v-else
autoplay
muted
src="/video/vid-3.mp4"
class="overlay-image select-none absolute object-cover size-full brightness-[95%]"
/>
</Transition>
<div <div
:style="{ :style="{
left: `${clipPathPercent}%`, left: `${clipPathPercent}%`,
@@ -0,0 +1,135 @@
<script lang="ts" setup>
// state
const { $gsap: gsap, $ScrollTrigger: ScrollTrigger } = useNuxtApp();
const slides = [
{
id: 1,
title: "موبایل iPhone 16 Pro Max",
description: "لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است. چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم.",
image: "/img/showcase-1.png"
},
{
id: 2,
title: "موبایل iPhone 16 Pro Max",
description: "لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است. چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم.",
image: "/img/showcase-2.png"
},
{
id: 3,
title: "موبایل iPhone 16 Pro Max",
description: "لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است. چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم.",
image: "/img/showcase-1.png"
}
];
let gsapTimeline: gsap.core.Timeline;
// lifecycle
onMounted(() => {
gsapTimeline = gsap.timeline();
gsapTimeline
.fromTo("#header-navbar", {
background: "white",
filter: "invert(0%)"
}, {
background: "transparent",
filter: "invert(100%)"
});
const showcaseElements = gsap.utils.toArray<HTMLElement>(".showcase-slide");
showcaseElements.forEach((element, index) => {
gsapTimeline.fromTo(element, index === 0 ? {
opacity: 1,
scale: 1,
// rotateX: -25,
y: 0,
ease: "none"
} : {
opacity: 0,
scale: 0.97,
// rotateX: -25,
y: 20,
ease: "none"
}, {
opacity: 1,
scale: 1,
// rotateX: 0,
y: 0,
ease: "none"
}, index === 0 ? "-=0%" : undefined);
if (index < showcaseElements.length - 1) {
gsapTimeline.to(element, {
opacity: 0,
scale: 1.03,
// rotateX: 25,
y: -20,
ease: "none"
});
}
});
gsapTimeline.to("#header-navbar", {
background: "white",
filter: "invert(0%)"
});
ScrollTrigger.create({
trigger: "#products-showcase-container",
animation: gsapTimeline,
scrub: 1,
pin: true,
start: "top top",
// markers: true,
end: "bottom top"
});
});
onUnmounted(() => {
gsapTimeline.progress(1).pause();
gsapTimeline.kill();
});
</script>
<template>
<div
id="products-showcase-container"
class="mt-80 mb-40 perspective-midrange w-full h-[125svh] bg-black flex items-center justify-center"
>
<div
v-for="slide in slides"
:key="slide.id"
class="showcase-slide origin-bottom absolute size-full bg-transparent flex items-center justify-center"
>
<div
class="blur-[150px] w-[600px] h-[80px] bg-white/70 absolute z-10"
/>
<img
class="w-[650px] z-20 mb-30"
:src="slide.image"
:style="{
mask: 'linear-gradient(to bottom, black 0%, rgba(0,0,0,0) 100%)',
}"
alt=""
/>
<div class="flex flex-col items-center justify-center gap-4 text-center absolute z-20 mt-20">
<span class="text-white typo-h-3">
{{ slide.title }}
</span>
<p class="text-white max-w-[750px] typo-p-lg">
{{ slide.description }}
</p>
</div>
</div>
</div>
</template>
+22 -8
View File
@@ -1,3 +1,13 @@
<script lang="ts" setup>
// provide / inject
import type { ProductVariantProvideType } from "~/pages/product/[id].vue";
const { selectedVariant } = inject("productVariant") as ProductVariantProvideType;
</script>
<template> <template>
<section class="w-full p-[5rem] flex flex-col gap-y-[1.5rem]"> <section class="w-full p-[5rem] flex flex-col gap-y-[1.5rem]">
<div class="w-full flex"> <div class="w-full flex">
@@ -16,23 +26,27 @@
class="w-full grid grid-cols-2 gap-y-[1.5rem] gap-x-[3rem]" class="w-full grid grid-cols-2 gap-y-[1.5rem] gap-x-[3rem]"
> >
<div <div
v-for="i in 4" v-for="inPackItem in selectedVariant.in_pack_items"
class="w-full flex-col-center gap-[.75rem]" class="w-full flex-col-center gap-[.75rem]"
> >
<div <div
class="size-[6.25rem] rounded-full border-slate-200 bg-white flex-center" class="size-[6.25rem] rounded-full border-slate-200 bg-white flex-center"
> >
<Icon name="ci:flag" size="44" /> <div class="size-11 relative">
<img
class="size-full absolute object-cover"
:src="inPackItem.cover"
:alt="inPackItem.item_title"
/>
</div>
</div> </div>
<span class="text-black typo-p-md">Headphones</span> <span class="text-black typo-p-md">
{{ inPackItem.item_title}}
</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
</template> </template>
<script setup lang="ts"></script>
<style scoped></style>
+85 -28
View File
@@ -4,6 +4,7 @@
import useGetProduct from "~/composables/api/product/useGetProduct"; import useGetProduct from "~/composables/api/product/useGetProduct";
import { sanitize } from "isomorphic-dompurify"; import { sanitize } from "isomorphic-dompurify";
import type { ProductVariantProvideType } from "~/pages/product/[id].vue";
// state // state
@@ -12,47 +13,77 @@ const id = route.params.id as string | undefined;
const { data: product } = useGetProduct(id); const { data: product } = useGetProduct(id);
const quantity = ref(1); const selectedVariantId = ref(product.value!.variants[0].id);
const selectedQuantity = ref(1);
const selectedSlide = ref(product.value!.variants[0].images[0].id);
const selectedSlide = ref(0); const selectedColor = ref(product.value!.colors[0]);
// provide / inject
const { selectedVariant, changeSelectedVariant } = inject("productVariant") as ProductVariantProvideType;
// computed // computed
const slides = computed(() => {
return [
{
id: 0,
picture: product.value!.image1
},
{
id: 1,
picture: product.value?.image2 ?? product.value!.image1
},
{
id: 2,
picture: product.value!.image3 ?? product.value!.image1
}
];
});
const sanitizedProductDescription = computed(() => { const sanitizedProductDescription = computed(() => {
return sanitize(product.value!.description); return sanitize(product.value!.description);
}); });
// watch
watch(() => selectedVariantId.value, (newId) => {
const newVariant = product.value!.variants.find(variant => variant.id === newId)!;
changeSelectedVariant(newVariant);
});
watch(() => selectedColor.value, (newValue) => {
const filteredVariants = product.value!.variants.filter(v => v.color === newValue);
selectedVariantId.value = filteredVariants[0].id;
selectedVariant.value = filteredVariants[0];
}, {
immediate: true
});
watch(() => selectedVariant.value, (newValue) => {
selectedQuantity.value = 1;
selectedSlide.value = newValue.images[0].id;
});
</script> </script>
<template> <template>
<div class="flex gap-12 container pt-[5rem]"> <div class="flex gap-16 container pt-[5rem] pb-28">
<Slider <Slider
class="flex-1" class="flex-1"
v-model:selectedSlide="selectedSlide" v-model:selectedSlide="selectedSlide"
:slides="slides" :slides="selectedVariant.images"
/> />
<div class="flex-1 flex flex-col gap-3 mt-12"> <div class="flex-1 flex flex-col gap-3 mt-12">
<span class="typo-label-sm"> سامسونگ </span> <span class="typo-label-sm"> سامسونگ </span>
<h1 class="typo-h-2"> {{ product!.name }} </h1> <h1 class="typo-h-2"> {{ product!.name }} </h1>
<div class="flex w-full items-center justify-between"> <div class="flex w-full items-center justify-between h-[85px]">
<span class="typo-p-2xl"> {{ product!.price }} </span> <div class="flex items-end gap-4">
<div class="flex flex-col gap-2">
<span
v-if="selectedVariant.discount > 0"
class="typo-p-lg relative flex-center w-fit"
:class="'after:w-full after:h-[2px] after:bg-black after:absolute'"
>
{{ selectedVariant.price }}
</span>
<span
class="typo-p-2xl relative flex-center w-fit font-medium"
>
{{ selectedVariant.discount > 0 ? selectedVariant.price : selectedVariant.price }}
</span>
</div>
<div v-if="selectedVariant.discount > 0"
class="text-white bg-blue-500 mb-1 px-4 py-2 text-xs rounded-full flex items-center gap-1">
<Icon name="material-symbols:percent" class="size-4" />
{{ selectedVariant.discount }}
درصد تخفیف
</div>
</div>
<Rating /> <Rating />
</div> </div>
@@ -61,11 +92,37 @@ const sanitizedProductDescription = computed(() => {
v-html="sanitizedProductDescription" v-html="sanitizedProductDescription"
/> />
<div class="w-full flex flex-col gap-6 mt-4"> <div class="flex items-center gap-4">
<span class="typo-p-lg">
تنوع رنگی :
</span>
<div class="flex items-center gap-4 py-4">
<ColorCircle
v-for="color in product!.colors"
:key="color"
@click="selectedColor = color"
:selected="selectedColor === color "
:style="{backgroundColor: color}"
class="cursor-pointer"
/>
</div>
</div>
<div class="flex items-center gap-6 flex-wrap">
<ProductVariant
@click="variant.in_stock > 0 ? selectedVariantId = variant.id : undefined"
v-for="variant in product!.variants.filter(p => p.color === selectedColor)"
:key="variant.id"
:variantDetail="variant"
:isSelected="selectedVariantId === variant.id"
/>
</div>
<div class="w-full flex flex-col gap-6 mt-10">
<RemainQuantity <RemainQuantity
:maxQuantity="product!.in_stock" :maxQuantity="selectedVariant.in_stock"
:quantity="quantity" :quantity="selectedQuantity"
/> />
<div class="w-full flex gap-3 flex-col"> <div class="w-full flex gap-3 flex-col">
@@ -74,8 +131,8 @@ const sanitizedProductDescription = computed(() => {
افزودن به سبد خرید افزودن به سبد خرید
</Button> </Button>
<QuantityCounter <QuantityCounter
v-model="quantity" v-model="selectedQuantity"
:max="product!.in_stock" :max="selectedVariant.in_stock"
/> />
</div> </div>
<Button class="w-full rounded-full" variant="outlined"> <Button class="w-full rounded-full" variant="outlined">
@@ -0,0 +1,77 @@
<script lang="ts" setup>
// types
type Props = {
isSelected: boolean;
variantDetail: ProductVariant;
}
// props
defineProps<Props>();
</script>
<template>
<div
:class="[
isSelected ? 'border-blue-500' : 'border-slate-300',
variantDetail.in_stock > 0 ? 'cursor-pointer' : '!border-slate-100'
]"
class="transition-all min-w-[350px] w-full duration-100 p-4 rounded-150 border-[2px] flex gap-4"
>
<div>
<div
:class="[
isSelected ? 'ring-blue-500 bg-blue-500' : 'ring-slate-300 bg-slate-300',
variantDetail.in_stock > 0 ? '' : '!ring-slate-100 !bg-slate-300-100'
]"
class="size-3 mt-2 ring-2 ring-offset-2 rounded-full "
>
</div>
</div>
<div class="w-full">
<div class="w-full flex justify-between items-center gap-2">
<span class="text-xl font-medium">
{{ variantDetail.price }}
</span>
<div
v-if="variantDetail.discount > 0"
:class="variantDetail.in_stock > 0 ? 'bg-blue-500' :'bg-slate-400/60'"
class="text-white mb-1 px-3 py-1 text-xs rounded-full w-fit flex items-center justify-center gap-1"
>
<template v-if="variantDetail.in_stock > 0">
<Icon name="material-symbols:percent" class="size-3.5" />
<span class="mt-px">
{{ variantDetail.discount }}
</span>
</template>
<span v-else class="mt-px">
اتمام موجودی
</span>
</div>
</div>
<div class="w-full flex items-center flex-wrap gap-3 max-w-[400px] mt-4">
<!-- <div-->
<!-- class="flex items-center gap-2 text-sm rounded-full border border-slate-400 px-4 h-[40px]"-->
<!-- >-->
<!-- <span>رنگ</span>-->
<!-- <ColorCircle class="size-[22px]" :style="{backgroundColor:variantDetail.color}" />-->
<!-- </div>-->
<div
v-for="attribute in variantDetail.product_attributes"
class="flex items-center gap-2 text-sm rounded-full border border-slate-400 px-4 h-[40px]"
>
<span>{{ attribute.attribute_type.name }}</span>
<span>{{ attribute.value }}</span>
</div>
</div>
</div>
</div>
</template>
+7 -5
View File
@@ -3,6 +3,7 @@
// import // import
import useGetProduct from "~/composables/api/product/useGetProduct"; import useGetProduct from "~/composables/api/product/useGetProduct";
import type { ProductVariantProvideType } from "~/pages/product/[id].vue";
// state // state
@@ -11,13 +12,14 @@ const id = route.params.id as string | undefined;
const { data: product } = useGetProduct(id); const { data: product } = useGetProduct(id);
const { selectedVariant } = inject("productVariant") as ProductVariantProvideType;
</script> </script>
<template> <template>
<section v-if="product?.video" class="h-[110svh] w-full relative bg-black mt-[5rem]"> <section v-if="selectedVariant?.video" class="h-[110svh] w-full relative bg-black mt-[5rem]">
<video <video
:src="product.video" :src="selectedVariant.video"
class="object-cover absolute size-full" class="object-cover absolute size-full"
muted muted
autoplay autoplay
@@ -25,9 +27,9 @@ const { data: product } = useGetProduct(id);
/> />
<div class="size-full absolute inset-0 bg-black/20" /> <div class="size-full absolute inset-0 bg-black/20" />
<StickyCard <StickyCard
color="آبی" :color="selectedVariant.color!"
:price="product!.price" :price="selectedVariant.price"
picture="/img/product-6.webp" :picture="selectedVariant.images[0].image"
:title="product!.name" :title="product!.name"
class="absolute right-10 bottom-10" class="absolute right-10 bottom-10"
/> />
@@ -7,7 +7,7 @@ import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
export type GetProductResponse = Product; export type GetProductResponse = Product;
const useGetDoc = (id: string | number | undefined) => { const useGetProduct = (id: string | number | undefined) => {
// state // state
@@ -22,8 +22,15 @@ const useGetDoc = (id: string | number | undefined) => {
return useQuery({ return useQuery({
queryKey: [QUERY_KEYS.product, id], queryKey: [QUERY_KEYS.product, id],
queryFn: () => handleGetProduct(id) queryFn: () => handleGetProduct(id),
select: (product) => {
const copyOfProduct = { ...product };
copyOfProduct.variants = copyOfProduct.variants.sort((a, b) => b.in_stock - a.in_stock);
return copyOfProduct;
}
}); });
}; };
export default useGetDoc; export default useGetProduct;
@@ -5,7 +5,7 @@ import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
// types // types
export type GetProductsResponse = ApiPaginated<Product>; export type GetProductsResponse = ApiPaginated<ProductListItem>;
export type GetProductsFilters = { export type GetProductsFilters = {
search?: string | undefined; search?: string | undefined;
@@ -40,8 +40,8 @@ const useGetProducts = (params?: ComputedRef<GetProductsFilters>) => {
price_gte: params?.price_gte, price_gte: params?.price_gte,
price_lte: params?.price_lte, price_lte: params?.price_lte,
offset: Number(params?.page) * 9 - 9, offset: Number(params?.page) * 9 - 9,
limit: 9, limit: 9
}, }
} }
); );
@@ -51,7 +51,7 @@ const useGetProducts = (params?: ComputedRef<GetProductsFilters>) => {
return useQuery({ return useQuery({
staleTime: 60 * 1000, staleTime: 60 * 1000,
queryKey: [QUERY_KEYS.products, params], queryKey: [QUERY_KEYS.products, params],
queryFn: () => handleGetProducts(params?.value), queryFn: () => handleGetProducts(params?.value)
}); });
}; };
+3 -3
View File
@@ -52,9 +52,9 @@ if (response.isError) {
<!-- This is for masonry js package --> <!-- This is for masonry js package -->
<ClientOnly>
<ArticlesList :articles="articles!.results" /> <ArticlesList :articles="articles!.results" />
</ClientOnly>
<div class="w-full flex-center pt-24 pb-10"> <div class="w-full flex-center pt-24 pb-10">
<Pagination :items="[]" :total="100" /> <Pagination :items="[]" :total="100" />
+3 -1
View File
@@ -23,10 +23,12 @@ if (response.isError) {
<template> <template>
<div class="w-full"> <div class="w-full">
<!-- <LoadingOverlay />-->
<Hero /> <Hero />
<Preview /> <Preview />
<Categories /> <ProductsShowcase />
<ProductsSlider title="محصولات پرفروش" /> <ProductsSlider title="محصولات پرفروش" />
<Categories />
<Brands /> <Brands />
<MostRecentComments /> <MostRecentComments />
<LatestStories /> <LatestStories />
+21 -1
View File
@@ -1,9 +1,20 @@
<script lang="ts" setup> <script lang="ts" setup>
// import
import ChatButton from "~/components/product/ChatBox/ChatButton.vue"; import ChatButton from "~/components/product/ChatBox/ChatButton.vue";
import useGetProduct from "~/composables/api/product/useGetProduct"; import useGetProduct from "~/composables/api/product/useGetProduct";
import useGetComments from "~/composables/api/product/useGetComments"; import useGetComments from "~/composables/api/product/useGetComments";
// type
export type ProductVariantProvideType = {
selectedVariant: Ref<ProductVariant>,
changeSelectedVariant: (value: ProductVariant) => void
}
// state
const route = useRoute(); const route = useRoute();
const id = route.params.id as string | undefined; const id = route.params.id as string | undefined;
@@ -12,6 +23,15 @@ const page = ref(1);
const { suspense: suspenseProduct } = useGetProduct(id); const { suspense: suspenseProduct } = useGetProduct(id);
const { suspense: suspenseComments } = useGetComments(id, page); const { suspense: suspenseComments } = useGetComments(id, page);
const selectedVariant = ref<ProductVariant>();
// provide / inject
provide("productVariant", {
selectedVariant,
changeSelectedVariant: (value: ProductVariant) => selectedVariant.value = value
});
// ssr // ssr
const productResponse = await suspenseProduct(); const productResponse = await suspenseProduct();
@@ -27,7 +47,7 @@ if (productResponse.isError || commentsResponse.isError) {
</script> </script>
<template> <template>
<div class="w-full flex flex-col gap-20"> <div class="w-full flex flex-col ">
<ProductHero /> <ProductHero />
<ProductVideo /> <ProductVideo />
<ProductComments /> <ProductComments />
Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 MiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+65 -19
View File
@@ -24,27 +24,73 @@ declare global {
birth_date: string; birth_date: string;
}; };
type Product = { type ProductVariantAttribute = {
id: number; "id": number,
price: string; "attribute_type": {
name: string; "id": number,
description: string; "name": string
currency: string; },
video: string | null; "value": string
image1: string; }
image2: string;
image3: string; type ProductImage = {
rating: number; "id": number,
view: number; "name": string,
sell: number; "image": string
in_stock: number; }
discount: number;
slug: string;
meta_description: string | null; type ProductDetailItem = {
meta_keywords: null; "id": number,
meta_rating: number | null; "title": string,
"detail_text1": string,
"detail_text2": string,
"detail_text3": string,
"detail_text4": string
}
type ProductDetail = {
"id": number,
"detail": ProductDetailItem[],
"detail_category": number
}
type ProductInPackItem = {
"id": number,
"item_title": string,
"cover": string
}
type ProductVariant = {
"id": number,
"product_attributes": ProductVariantAttribute[],
"price": string,
"in_pack_items": ProductInPackItem[],
"details": ProductDetail[],
"images": ProductImage[],
"in_stock": number,
"discount": number,
"color": string,
"video": string | null
}; };
type Product = {
"id": number,
"variants": ProductVariant[],
"related_products": ProductListItem[],
"name": string,
"description": string,
"rating": number,
"slug": string,
"meta_description": string | null,
"meta_keywords": string | null,
"meta_rating": number,
"category": number,
"colors": string[]
}
type ProductListItem = Pick<Product, "id" | "variants" | "name" | "rating" | "slug" | "category" | "colors">
type Article = { type Article = {
id: number; id: number;
title: string; title: string;