merge
This commit is contained in:
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
+23
@@ -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='کد پستی'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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 = "تلاشهای نفوذ"
|
||||||
@@ -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'),
|
||||||
]
|
]
|
||||||
@@ -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')
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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']
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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='وضعیت تیکت'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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='ادمین')
|
||||||
|
|||||||
@@ -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',)
|
||||||
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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.
Vendored
+65
-19
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user