This commit is contained in:
Mamalizz
2025-03-09 20:40:06 +03:30
32 changed files with 400 additions and 254 deletions
+1 -1
View File
@@ -31,5 +31,5 @@ jobs:
script: | script: |
cd /root/hshop/ cd /root/hshop/
docker compose down docker compose down
docker compose build docker compose build --no-cache
docker compose up -d docker compose up -d
+1
View File
@@ -0,0 +1 @@
.idea
+1 -1
View File
@@ -61,7 +61,7 @@ Code: {otp}"""
# response = sms_api.send_otp_sms(otp_input) # response = sms_api.send_otp_sms(otp_input)
if response['statusCode'] == 200: if response['statusCode'] == 200:
return Response({'detail': 'OTP sent successfully'}, status=status.HTTP_200_OK) return Response({'detail': f'OTP sent successfully {otp}'}, status=status.HTTP_200_OK)
else: else:
print(response) print(response)
return Response({'detail': f'مشکلی در ارسال کد رخ داد'}, status=status.HTTP_200_OK) return Response({'detail': f'مشکلی در ارسال کد رخ داد'}, status=status.HTTP_200_OK)
+4 -1
View File
@@ -1,6 +1,7 @@
from rest_framework import serializers from rest_framework import serializers
from .models import BlogModel from .models import BlogModel
from account.models import User from account.models import User
from product.serializers import SubCategorySerializer
class AuthorSerializer(serializers.ModelSerializer): class AuthorSerializer(serializers.ModelSerializer):
full_name = serializers.SerializerMethodField() full_name = serializers.SerializerMethodField()
@@ -14,6 +15,7 @@ class AuthorSerializer(serializers.ModelSerializer):
return 'ادمین وبسایت' return 'ادمین وبسایت'
class BlogSerilizer(serializers.ModelSerializer): class BlogSerilizer(serializers.ModelSerializer):
category = SubCategorySerializer()
author = AuthorSerializer() author = AuthorSerializer()
class Meta: class Meta:
model = BlogModel model = BlogModel
@@ -22,6 +24,7 @@ class BlogSerilizer(serializers.ModelSerializer):
class AllBlogSerilizer(serializers.ModelSerializer): class AllBlogSerilizer(serializers.ModelSerializer):
author = AuthorSerializer() author = AuthorSerializer()
category = SubCategorySerializer()
class Meta: class Meta:
model = BlogModel model = BlogModel
exclude = ('is_published', 'content', 'summery', ) exclude = ('is_published', 'content',)
+11 -1
View File
@@ -30,7 +30,7 @@ EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD")
DEFAULT_FROM_EMAIL = os.getenv("SECRET_KEY") DEFAULT_FROM_EMAIL = os.getenv("SECRET_KEY")
# Security and Debugging # Security and Debugging
SECRET_KEY = os.getenv("SECRET_KEY") SECRET_KEY = os.getenv("SECRET_KEY") or 'this is just for cron job dont judge me'
DEBUG = True DEBUG = True
BASE_DIR = Path(__file__).resolve().parent.parent.parent BASE_DIR = Path(__file__).resolve().parent.parent.parent
@@ -65,6 +65,7 @@ INSTALLED_APPS = [
"rest_framework.authtoken", "rest_framework.authtoken",
"import_export", "import_export",
"django_jalali", "django_jalali",
'django_crontab',
# Custom Apps # Custom Apps
"product", "product",
"account", "account",
@@ -233,3 +234,12 @@ AWS_S3_OBJECT_PARAMETERS = {
'ACL': 'public-read', 'ACL': 'public-read',
} }
# ==============================================================================
# django CRONJOBS
# ==============================================================================
CRONJOBS = [
('* * * * *', 'product.cron.update_product_prices', f'>> {BASE_DIR}/logfile.log 2>&1'),
]
+1
View File
@@ -13,5 +13,6 @@ COPY . /app/
CMD ["sh", "-c", "python manage.py makemigrations && \ CMD ["sh", "-c", "python manage.py makemigrations && \
python manage.py migrate && \ python manage.py migrate && \
python manage.py crontab add && \
python manage.py collectstatic --no-input && \ python manage.py collectstatic --no-input && \
gunicorn core.wsgi:application --bind 0.0.0.0:8000 --workers 3"] gunicorn core.wsgi:application --bind 0.0.0.0:8000 --workers 3"]
@@ -0,0 +1,19 @@
# Generated by Django 5.1.2 on 2025-03-08 18:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('order', '0010_alter_orderitemmodel_quantity'),
]
operations = [
migrations.AddField(
model_name='orderitemmodel',
name='price',
field=models.PositiveIntegerField(default=0, verbose_name='قیمت'),
preserve_default=False,
),
]
+2 -1
View File
@@ -97,13 +97,14 @@ class OrderModel(models.Model):
class OrderItemModel(models.Model): class OrderItemModel(models.Model):
order = models.ForeignKey(OrderModel, on_delete=models.CASCADE, related_name='items', verbose_name='سفارش') order = models.ForeignKey(OrderModel, on_delete=models.CASCADE, related_name='items', verbose_name='سفارش')
quantity = models.PositiveSmallIntegerField(verbose_name="تعداد") quantity = models.PositiveSmallIntegerField(verbose_name="تعداد")
price = models.PositiveIntegerField(verbose_name='قیمت')
product = models.ForeignKey(ProductVariant, on_delete=models.PROTECT, verbose_name="محصول") product = models.ForeignKey(ProductVariant, on_delete=models.PROTECT, verbose_name="محصول")
class Meta: class Meta:
verbose_name = 'ایتم سبد خرید' verbose_name = 'ایتم سبد خرید'
verbose_name_plural = 'ایتم های سبد خرید' verbose_name_plural = 'ایتم های سبد خرید'
def total(self): def total(self):
return self.quantity * self.product.get_toman_price() return self.quantity * self.product.price
def total_with_discount(self): def total_with_discount(self):
return self.quantity * self.product.get_toman_price_after_discount() return self.quantity * self.product.get_toman_price_after_discount()
+4 -1
View File
@@ -1,12 +1,15 @@
from rest_framework import serializers from rest_framework import serializers
from .models import OrderItemModel, OrderModel from .models import OrderItemModel, OrderModel
from product.serializers import ProductVariantSerialzier
class OrderItemSerailzier(serializers.ModelSerializer): class OrderItemSerailzier(serializers.ModelSerializer):
product = serializers.SerializerMethodField()
class Meta: class Meta:
model = OrderItemModel model = OrderItemModel
fields = "__all__" fields = "__all__"
read_only_fields = ('order', 'product') read_only_fields = ('order', 'product')
def get_product(self, obj):
return ProductVariantSerialzier(instance=obj.product, context={'request': self.context.get('request')}).data
class CartSerializer(serializers.ModelSerializer): class CartSerializer(serializers.ModelSerializer):
items = OrderItemSerailzier(many=True) items = OrderItemSerailzier(many=True)
+15 -2
View File
@@ -9,7 +9,7 @@ from django.contrib.postgres.fields import ArrayField
from unfold.widgets import UnfoldAdminColorInputWidget 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
from django.shortcuts import redirect
@@ -140,8 +140,10 @@ class ProductVariantInLine(StackedInline):
show_change_link = True show_change_link = True
tab = True tab = True
min_num = 1 min_num = 1
readonly_fields = ['price']
# inlines = [DetailModelInLine] # inlines = [DetailModelInLine]
autocomplete_fields = ['product_attributes', 'in_pack_items', 'images', 'details'] autocomplete_fields = ['product_attributes', 'in_pack_items', 'images', 'details']
fields = ['images', 'video','input_price', 'min_price', 'currency', 'price', 'discount','in_stock', 'color', 'product_attributes', 'in_pack_items', 'details','sell']
# search_fields = [''] # search_fields = ['']
@@ -156,6 +158,7 @@ class ProductVariantAdmin(ModelAdmin, ImportExportModelAdmin):
export_form_class = ExportForm export_form_class = ExportForm
autocomplete_fields = ['product_attributes', 'images', 'in_pack_items', 'details'] autocomplete_fields = ['product_attributes', 'images', 'in_pack_items', 'details']
warn_unsaved_form = True warn_unsaved_form = True
readonly_fields = ['price']
# inlines = [DetailModelInLine] # inlines = [DetailModelInLine]
@admin.register(ProductModel) @admin.register(ProductModel)
@@ -169,6 +172,7 @@ class ProductModelAdmin(ModelAdmin, ImportExportModelAdmin):
autocomplete_fields = ['related_products', ] autocomplete_fields = ['related_products', ]
# compressed_fields = True # compressed_fields = True
warn_unsaved_form = True warn_unsaved_form = True
actions_list = ['redirect_to_learn', 'update_products_price']
list_display = ['display_image', 'display_price', 'view', 'show', 'rating', 'category', ] list_display = ['display_image', 'display_price', 'view', 'show', 'rating', 'category', ]
fieldsets = ( fieldsets = (
('فیلد های اصلی', {'fields': ('name', 'description', 'category', 'related_products', 'show',), "classes": ["tab"],}), ('فیلد های اصلی', {'fields': ('name', 'description', 'category', 'related_products', 'show',), "classes": ["tab"],}),
@@ -188,7 +192,7 @@ class ProductModelAdmin(ModelAdmin, ImportExportModelAdmin):
def display_price(self, obj): def display_price(self, obj):
if obj.variants.all().first(): if obj.variants.all().first():
return obj.variants.all().first().get_toman_price() return obj.variants.all().first().price
display_price.short_description = 'قیمت تومانی' display_price.short_description = 'قیمت تومانی'
@display(description='محصول', header=True) @display(description='محصول', header=True)
@@ -209,6 +213,15 @@ class ProductModelAdmin(ModelAdmin, ImportExportModelAdmin):
# "squared": True, # "squared": True,
}, },
] ]
@action(description=f"اپدیت قیمت ها")
def update_products_price(self, request):
print('from the button')
ProductVariant.update_all_prices()
messages.success(request, f"قیمت {ProductVariant.objects.all().count()} تنوع محصول اپدیت شد")
return redirect("admin:product_productmodel_changelist")
# @display( # @display(
# description=("نمایش در صفحه ی اصلی"), # description=("نمایش در صفحه ی اصلی"),
# label={ # label={
+5
View File
@@ -0,0 +1,5 @@
from product.models import ProductVariant
def update_product_prices():
print('calling the update product prices from cron')
ProductVariant.update_all_prices()
@@ -0,0 +1,37 @@
# Generated by Django 5.1.2 on 2025-03-08 18:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('product', '0033_alter_productvariant_details'),
]
operations = [
migrations.RemoveField(
model_name='productvariant',
name='max_price',
),
migrations.AddField(
model_name='productvariant',
name='input_price',
field=models.PositiveIntegerField(default=0, verbose_name='قیمت ورودی'),
),
migrations.AlterField(
model_name='productvariant',
name='color',
field=models.CharField(blank=True, max_length=7, null=True, verbose_name='رنگ'),
),
migrations.AlterField(
model_name='productvariant',
name='details',
field=models.ManyToManyField(related_name='product', to='product.productdetailmodel', verbose_name='جزییات محصول'),
),
migrations.AlterField(
model_name='productvariant',
name='price',
field=models.PositiveIntegerField(blank=True, null=True, verbose_name='قیمت محاسبه شده'),
),
]
+36 -14
View File
@@ -215,14 +215,13 @@ class ProductDetailModel(models.Model):
# def __str__(self): # def __str__(self):
# return f'جزيیات محصول {self.product}' # return f'جزيیات محصول {self.product}'
class ProductVariant(models.Model): class ProductVariant(models.Model):
product = models.ForeignKey(ProductModel, on_delete=models.CASCADE, related_name='variants', verbose_name='محصول') product = models.ForeignKey(ProductModel, on_delete=models.CASCADE, related_name='variants', verbose_name='محصول')
product_attributes = models.ManyToManyField(AttributeValue, verbose_name='ویژگی‌ها', related_name='variant') product_attributes = models.ManyToManyField(AttributeValue, verbose_name='ویژگی‌ها', related_name='variant')
in_stock = models.PositiveIntegerField(default=0, verbose_name='تعداد موجود') in_stock = models.PositiveIntegerField(default=0, verbose_name='تعداد موجود')
price = models.PositiveIntegerField(default=0, verbose_name='قیمت') price = models.PositiveIntegerField(verbose_name='قیمت محاسبه شده', blank=True, null=True)
input_price = models.PositiveIntegerField(default=0, verbose_name='قیمت ورودی')
min_price = models.PositiveIntegerField(verbose_name='قیمت کف', help_text='این قیمت برای کف قیمتی محصول در نظر گرفته میشود') min_price = models.PositiveIntegerField(verbose_name='قیمت کف', help_text='این قیمت برای کف قیمتی محصول در نظر گرفته میشود')
max_price = models.PositiveIntegerField(verbose_name='قیمت سقف', help_text='این قیمت برای سقف قیمتی محصول در نظر گرفته میشود')
currency_type = ( currency_type = (
('dollor', 'دلار'), ('dollor', 'دلار'),
('toman', 'تومان'), ('toman', 'تومان'),
@@ -232,33 +231,56 @@ class ProductVariant(models.Model):
sell = models.IntegerField(default=0, verbose_name='فروش') sell = models.IntegerField(default=0, verbose_name='فروش')
currency = models.CharField(verbose_name='نوع ارز', max_length=20, choices=currency_type) currency = models.CharField(verbose_name='نوع ارز', max_length=20, choices=currency_type)
discount = models.SmallIntegerField(default=0, verbose_name='تخفیف') discount = models.SmallIntegerField(default=0, verbose_name='تخفیف')
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') details = models.ManyToManyField(ProductDetailModel, verbose_name='جزییات محصول', related_name='product')
class Meta: class Meta:
verbose_name = 'تنوع محصول' verbose_name = 'تنوع محصول'
verbose_name_plural = 'تنوع‌های محصول' verbose_name_plural = 'تنوع‌های محصول'
def __str__(self): def __str__(self):
return f"{self.product.name} - {', '.join(str(attr) for attr in self.product_attributes.all())}" return f"{self.product.name} - {', '.join(str(attr) for attr in self.product_attributes.all())}"
def get_toman_price(self, dollor_price=None): def set_or_update_price(self, dollor_price=None):
if not dollor_price: if not dollor_price:
dollor_object, _ = DollorModel.objects.get_or_create(unique_filed='unique') dollor_object, _ = DollorModel.objects.get_or_create(unique_filed='unique')
dollor_price = dollor_object.price dollor_price = dollor_object.price
dollar_to_dirham = 0.27
if dollor_price is None: if dollor_price is None:
raise ValidationError({"dollor_price": "The 'dollor_price' must be provided in the context for dollar pricing."}) raise ValidationError({"dollor_price": "The 'dollor_price' must be provided in the context for dollar pricing."})
dollar_to_dirham = 0.27
if self.currency == 'toman': if self.currency == 'toman':
toman_price = self.price toman_price = self.input_price
elif self.currency == 'dollor': elif self.currency == 'dollor':
toman_price = self.price * dollor_price toman_price = self.input_price * dollor_price
elif self.currency == 'derham': elif self.currency == 'derham':
toman_price = self.price * dollor_price * dollar_to_dirham toman_price = self.input_price * dollor_price * dollar_to_dirham
toman_price = toman_price if toman_price > self.min_price else self.min_price else:
return toman_price toman_price = self.input_price
self.price = max(toman_price, self.min_price)
def save(self, *args, **kwargs):
self.set_or_update_price()
super().save(*args, **kwargs)
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.price * ((100 - self.discount) / 100)
@classmethod
def update_all_prices(cls):
print('calling the update all prices ')
dollor_object, _ = DollorModel.objects.get_or_create(unique_filed='unique')
print(dollor_object.price)
dollor_object.update_price()
dollor_object.save()
dollor_price = dollor_object.price
print(dollor_object.price)
print('classmethod dollor price update ')
products = cls.objects.all()
for product in products:
product.set_or_update_price(dollor_price=dollor_price)
product.save()
+1 -6
View File
@@ -46,18 +46,13 @@ class ProductImageSerailizer(serializers.ModelSerializer):
class ProductVariantSerialzier(serializers.ModelSerializer): class ProductVariantSerialzier(serializers.ModelSerializer):
product_attributes = AttributeValueSerialzier(many=True) product_attributes = AttributeValueSerialzier(many=True)
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) 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', 'sell', 'currency', 'product', 'input_price')
def get_price(self, obj):
dollor_price = self.context.get('dollor_price')
toman_price = obj.get_toman_price(dollor_price=dollor_price)
return "{:,.0f} تومان".format(toman_price)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
+1
View File
@@ -25,6 +25,7 @@ django-cleanup==8.1.0
django-colorfield==0.11.0 django-colorfield==0.11.0
django-cors-headers==4.4.0 django-cors-headers==4.4.0
django-cron==0.6.0 django-cron==0.6.0
django-crontab==0.7.1
django-dbbackup==4.2.1 django-dbbackup==4.2.1
django-dirtyfields==1.9.3 django-dirtyfields==1.9.3
django-filter==24.3 django-filter==24.3
+1 -1
View File
@@ -24,7 +24,7 @@ services:
[ [
"sh", "sh",
"-c", "-c",
"python manage.py migrate && python manage.py collectstatic --no-input && gunicorn core.wsgi:application --bind 0.0.0.0:8000 --workers 3", "python manage.py migrate && python manage.py collectstatic --no-input && python manage.py crontab add && gunicorn core.wsgi:application --bind 0.0.0.0:8000 --workers 3",
] ]
networks: networks:
- default - default
+4 -4
View File
@@ -1,6 +1,6 @@
@utility input-outlined-error { @utility input-outlined-error {
@apply text-danger-600 border-danger-600; @apply text-danger-600 border-danger-600;
svg[class~=iconify] path { svg[class~=iconify]:not(.static-icon) path {
@apply stroke-danger-600; @apply stroke-danger-600;
} }
} }
@@ -11,7 +11,7 @@
@utility input-outlined-disabled { @utility input-outlined-disabled {
@apply text-slate-300 border-slate-200; @apply text-slate-300 border-slate-200;
svg[class~=iconify] path { svg[class~=iconify]:not(.static-icon) path {
@apply stroke-slate-300; @apply stroke-slate-300;
} }
} }
@@ -28,7 +28,7 @@
&:focus-within { &:focus-within {
@apply border-black text-black; @apply border-black text-black;
svg[class~=iconify] path { svg[class~=iconify]:not(.static-icon) path {
@apply stroke-black; @apply stroke-black;
} }
} }
@@ -37,7 +37,7 @@
@utility input-outlined { @utility input-outlined {
@apply text-slate-500 border-slate-200; @apply text-slate-500 border-slate-200;
svg[class~=iconify] path { svg[class~=iconify]:not(.static-icon) path {
@apply stroke-slate-500; @apply stroke-slate-500;
} }
} }
+18 -25
View File
@@ -10,6 +10,11 @@
@import "./fonts/yekan-bakh.css"; @import "./fonts/yekan-bakh.css";
@theme { @theme {
/* CONTAINER */
--app-container-padding: 20px;
/* COLORS */ /* COLORS */
--color-slate-50: hsl(210, 40%, 98%); --color-slate-50: hsl(210, 40%, 98%);
--color-slate-100: hsl(210, 40%, 96%); --color-slate-100: hsl(210, 40%, 96%);
@@ -128,6 +133,8 @@
/* ANIMATIONS */ /* ANIMATIONS */
--animate-marquee: marquee 20s linear infinite; --animate-marquee: marquee 20s linear infinite;
--animate-marquee-reverse: marquee 20s linear infinite reverse; --animate-marquee-reverse: marquee 20s linear infinite reverse;
--animate-fade-in: fadeIn 350ms ease-in-out;
--animate-slide-down: slideDown 300ms ease-out; --animate-slide-down: slideDown 300ms ease-out;
--animate-slide-up: slideUp 300ms ease-out; --animate-slide-up: slideUp 300ms ease-out;
--animate-overlay-show: overlayShow 150ms ease-in; --animate-overlay-show: overlayShow 150ms ease-in;
@@ -148,6 +155,16 @@
} }
} }
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideDown { @keyframes slideDown {
from { from {
height: 0; height: 0;
@@ -265,31 +282,7 @@
/* CONTAINER */ /* CONTAINER */
@utility container { @utility container {
@apply mx-auto px-6; @apply mx-auto px-[var(--app-container-padding)];
@screen 2xs {
@apply px-4;
}
@screen xs {
@apply px-4;
}
@screen sm {
@apply px-4;
}
@screen md {
@apply px-8;
}
@screen lg {
@apply px-12;
}
@screen xl {
@apply px-20;
}
} }
@layer { @layer {
+4 -4
View File
@@ -15,7 +15,7 @@ const props = withDefaults(defineProps<Props>(), {
disabled: false, disabled: false,
placeholder: "وارد نشده", placeholder: "وارد نشده",
}); });
const { variant, message, error, disabled, modelValue } = toRefs(props); const { variant, error, modelValue } = toRefs(props);
// emits // emits
@@ -29,7 +29,7 @@ const inputRef = ref<HTMLInputElement | null>(null);
const value = computed({ const value = computed({
get: () => modelValue.value ?? "", get: () => modelValue.value ?? "",
set: (value) => emit("update:modelValue", value), set: (value) => emit("update:modelValue", value)
}); });
const classes = computed(() => { const classes = computed(() => {
@@ -41,8 +41,8 @@ const classes = computed(() => {
"input-effects": !error.value, "input-effects": !error.value,
[variant.value === "solid" [variant.value === "solid"
? "input-solid-error" ? "input-solid-error"
: "input-outlined-error"]: error.value, : "input-outlined-error"]: error.value
}, }
]; ];
}); });
</script> </script>
@@ -0,0 +1,42 @@
<script lang="ts" setup>
// types
type Props = {
title: string,
products: ProductListItem[]
}
// props
defineProps<Props>();
</script>
<template>
<section class="w-full flex flex-col gap-10 md:gap-[4rem] py-[5rem] container">
<div class="w-full flex justify-between items-center">
<span class="text-black typo-h-6 max-sm:text-xl md:typo-h-5 lg:typo-h-4">
{{ title }}
</span>
<NuxtLink to="/products">
<Button variant="outlined" class="rounded-full max-sm:typo-label-sm max-sm:py-2" end-icon="ci:arrow-left">
نمایش همه
</Button>
</NuxtLink>
</div>
<div class="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-y-8 gap-5 sm:gap-8">
<ProductCard
v-for="product in products"
:key="product.id"
:id="product.id"
:title="product.name"
:picture="product.variants[0].images[0].image"
:colors="product.colors"
:price="product.variants[0].price"
:rate="product.rating"
:dark-layer="true"
/>
</div>
</section>
</template>
@@ -35,11 +35,11 @@ const highlights = ref<Highlight[]>([
<template> <template>
<section class="w-full border-t-[0.5px] border-slate-200"> <section class="w-full border-t-[0.5px] border-slate-200">
<div class="w-full flex-center py-[5rem] gap-[1.25rem] container"> <div class="w-full py-[5rem] gap-12 xs:gap-8 sm:gap-12 xl:gap-0 container grid grid-cols-1 xs:grid-cols-2 lg:grid-cols-4">
<template v-for="(highlight, index) in highlights" :key="index"> <template v-for="(highlight, index) in highlights" :key="index">
<div class="flex flex-col-center gap-[.75rem] w-1/4 px-5"> <div class="flex flex-col-center gap-[.75rem] px-5">
<img :src="highlight.icon" class="size-[90px]" alt="" /> <img :src="highlight.icon" class="size-[70px] md:size-[90px]" alt="" />
<div class="w-full flex-col-center gap-[.25rem]"> <div class="w-full flex-col-center gap-[.25rem]">
<span class="typo-sub-h-md text-black text-center"> <span class="typo-sub-h-md text-black text-center">
@@ -51,10 +51,10 @@ const highlights = ref<Highlight[]>([
</div> </div>
</div> </div>
<div <!-- <div-->
class="w-[1px] h-[5rem] bg-slate-200" <!-- class="w-[1px] h-[5rem] bg-slate-200"-->
v-if="index + 1 != highlights.length" <!-- v-if="index + 1 != highlights.length"-->
/> <!-- />-->
</template> </template>
</div> </div>
</section> </section>
@@ -33,60 +33,72 @@ const { colorObject } = useImageColor(`#product-image-${id.value}`);
<template> <template>
<NuxtLink :to="'/product/' + id"> <NuxtLink :to="'/product/' + id">
<div <div class="@container">
class="group relative size-full aspect-square rounded-2xl bg-white brightness-[98%] overflow-hidden p-6"
>
<img
:id="`product-image-${id}`"
:src="picture"
class="group-hover:scale-105 transition-transform duration-200 size-full object-contain absolute inset-0"
alt="product-background"
/>
<div <div
v-if="darkLayer" class="group relative size-full aspect-square rounded-xl @[280px]:rounded-2xl bg-white brightness-[98%] overflow-hidden p-6"
class="bg-linear-to-t inset-0 from-black/50 to-transparent to-55% absolute z-10 size-full"
/>
<div
class="flex justify-between items-center absolute px-6 pt-6 top-0 w-full inset-x-0"
>
<Rate v-if="rate" :rate="rate"/>
<Tag v-if="tag">
{{ tag }}
</Tag>
</div>
<div
:class="
colorObject?.isLight && !darkLayer
? 'text-black'
: 'text-white'
"
class="absolute inset-x-0 bottom-0 pb-4 md:pb-6 px-4 md:px-6 flex flex-row-reverse justify-between items-end z-10"
> >
<div class="flex flex-col gap-2 items-start w-full"> <img
<!-- <span class="typo-p-sm md:typo-p-md !font-medium">--> :id="`product-image-${id}`"
<!-- {{ brand }}--> :src="picture"
<!-- </span>--> class="group-hover:scale-105 transition-transform duration-200 size-full object-contain absolute inset-0"
<span class="typo-sub-h-md md:typo-sub-h-lg"> alt="product-background"
{{ title }} />
</span>
<div class="flex items-center justify-between w-full mt-1"> <div
<div class="flex items-center gap-2 md:mt-1"> v-if="darkLayer"
<ColorCircle class="bg-linear-to-t inset-0 from-black/50 to-transparent to-55% absolute z-10 size-full"
v-for="color in colors" />
:key="color"
:style="{ backgroundColor: color }" <div
class="!size-5 md:!size-6" class="flex justify-between items-center absolute px-4 @[280px]:px-6 pt-4 @[280px]:pt-6 top-0 w-full inset-x-0"
/> >
<Rate v-if="rate" :rate="rate" />
<Tag v-if="tag">
{{ tag }}
</Tag>
</div>
<div
:class="
colorObject?.isLight && !darkLayer
? 'text-black'
: 'text-white'
"
class="absolute inset-x-0 bottom-0 pb-4 @[280px]:pb-6 px-4 @[280px]:px-6 flex flex-row-reverse justify-between items-end z-10"
>
<div class="flex flex-col gap-2 items-start w-full">
<span class="@max-[280px]:hidden typo-sub-h-md @[280px]:typo-sub-h-lg truncate w-full">
{{ title }}
</span>
<div class="flex items-center justify-between w-full mt-1">
<div class="flex items-center gap-2 @[280px]:mt-1">
<ColorCircle
v-for="color in colors"
:key="color"
:style="{ backgroundColor: color }"
class="!size-5 @[280px]:!size-6"
/>
</div>
<span class="@max-[280px]:hidden typo-p-xs @[280px]:typo-p-md !font-semibold whitespace-nowrap">
{{ price }}
</span>
</div> </div>
<span class="typo-p-sm md:typo-p-md font-medium whitespace-nowrap">
{{ price }}
</span>
</div> </div>
</div> </div>
</div> </div>
<div class="flex flex-col gap-1 px-2 items-start w-full text-black mt-4 @[280px]:hidden">
<span class="typo-sub-h-sm w-full truncate">
{{ title }}
</span>
<div class="@[280px]:hidden flex items-center justify-between w-full mt-1">
<span class="typo-p-xs !font-semibold whitespace-nowrap">
{{ price }}
</span>
</div>
</div>
</div> </div>
</NuxtLink> </NuxtLink>
</template> </template>
+86 -86
View File
@@ -122,7 +122,7 @@ onMounted(() => {
end: "bottom top" end: "bottom top"
}); });
setTimeout(() => { const calculateOnResize = () => {
if (window.innerWidth > 768) { if (window.innerWidth > 768) {
gsap.to("#header-navbar", { gsap.to("#header-navbar", {
background: "transparent", background: "transparent",
@@ -133,19 +133,18 @@ onMounted(() => {
resetTimelineForMobile(); resetTimelineForMobile();
scrollTrigger.disable(); scrollTrigger.disable();
} }
}
setTimeout(() => {
calculateOnResize()
}, 100); }, 100);
setTimeout(() => {
calculateOnResize()
}, 200);
window.addEventListener("resize", () => { window.addEventListener("resize", () => {
if (window.innerWidth > 768) { calculateOnResize()
gsap.to("#header-navbar", {
background: "transparent",
filter: "invert(100%)"
});
scrollTrigger.enable();
} else {
resetTimelineForMobile();
scrollTrigger.disable();
}
}); });
}); });
@@ -170,7 +169,7 @@ onUnmounted(() => {
:centered-slides="true" :centered-slides="true"
:breakpoints="{ :breakpoints="{
768: { 768: {
spaceBetween : 40 spaceBetween : 40,
} }
}" }"
@swiper="onSwiper" @swiper="onSwiper"
@@ -178,96 +177,97 @@ onUnmounted(() => {
<SwiperSlide <SwiperSlide
v-for="(slide, index) in homeData!.sliders" v-for="(slide, index) in homeData!.sliders"
:key="slide.id" :key="slide.id"
class="max-md:container"
> >
<div <div class="max-md:container">
class="header-slider-item relative w-full overflow-hidden max-md:rounded-[20px]"
>
<template v-if="!!slide.video">
<video
:id="`slide-video-${index}`"
:muted="swiper_instance?.realIndex !== index ? true : isMuted"
loop
class="slide-video absolute inset-0 size-full object-cover brightness-90"
:src="slide.video"
/>
</template>
<img
v-else
class="absolute inset-0 size-full object-cover"
:src="slide.image!"
:alt="slide.title"
/>
<div class="size-full absolute z-10 bg-linear-to-t from-black/50 to-transparent" />
<div <div
:class="swiper_instance?.realIndex !== index ? 'opacity-0' : ''" class="header-slider-item relative w-full overflow-hidden max-md:rounded-[20px]"
class="w-full transition-opacity pb-6 xs:pb-10 lg:pb-16 px-6 xs:px-10 lg:px-16 gap-6 xs:gap-10 lg:gap-12 container flex flex-col h-full justify-end relative z-10"
> >
<template v-if="!!slide.video">
<video
:id="`slide-video-${index}`"
:muted="swiper_instance?.realIndex !== index ? true : isMuted"
loop
class="slide-video absolute inset-0 size-full object-cover brightness-90"
:src="slide.video"
/>
</template>
<div class="header-slider-item-child w-full"> <img
<div class="border-b border-white/10 pb-6 flex flex-col gap-2 md:gap-4"> v-else
<div class="flex items-center gap-4 lg:gap-8"> class="absolute inset-0 size-full object-cover"
<div :src="slide.image!"
class="hover:scale-110 size-[36px] md:size-[42px] lg:size-[50px] relative flex items-center justify-center"> :alt="slide.title"
<div class="size-full scale-75 bg-white absolute rounded-full animate-ping" /> />
<button
@click="isMuted = !isMuted" <div class="size-full absolute z-10 bg-linear-to-t from-black/50 to-transparent" />
class="transition-all cursor-pointer flex-center bg-white z-10 size-full rounded-full"
> <div
<Icon :class="swiper_instance?.realIndex !== index ? 'opacity-0' : ''"
:name="isMuted ? 'bi:volume-mute-fill' : 'bi:volume-up-fill'" class="w-full transition-opacity pb-6 xs:pb-10 lg:pb-16 px-6 xs:px-10 lg:px-16 gap-6 xs:gap-10 lg:gap-12 container flex flex-col h-full justify-end relative z-10"
class="text-black size-4 md:size-[18px] lg:size-[24px]" >
/>
</button> <div class="header-slider-item-child w-full">
<div class="border-b border-white/10 pb-6 flex flex-col gap-2 md:gap-4">
<div class="flex items-center gap-4 lg:gap-8">
<div
class="hover:scale-110 size-[36px] md:size-[42px] lg:size-[50px] relative flex items-center justify-center">
<div class="size-full scale-75 bg-white absolute rounded-full animate-ping" />
<button
@click="isMuted = !isMuted"
class="transition-all cursor-pointer flex-center bg-white z-10 size-full rounded-full"
>
<Icon
:name="isMuted ? 'bi:volume-mute-fill' : 'bi:volume-up-fill'"
class="text-black size-4 md:size-[18px] lg:size-[24px]"
/>
</button>
</div>
<h3 class="typo-h-6 md:typo-h-4 lg:typo-h-1 tracking-[-2px] text-white">
{{ slide.title }}
</h3>
</div> </div>
<h3 class="typo-h-6 md:typo-h-4 lg:typo-h-1 tracking-[-2px] text-white"> <div class="flex justify-between items-center">
{{ slide.title }}
</h3>
</div>
<div class="flex justify-between items-center">
<span class="truncate typo-p-xs md:typo-p-sm lg:typo-p-lg text-white"> <span class="truncate typo-p-xs md:typo-p-sm lg:typo-p-lg text-white">
{{ slide.description }} {{ slide.description }}
</span> </span>
<Button <Button
class="max-sm:hidden max-lg:typo-label-xs invert rounded-full hover:bg-transparent"> class="max-sm:hidden max-lg:typo-label-xs invert rounded-full hover:bg-transparent">
مشاهده مشاهده
</Button> </Button>
</div>
</div> </div>
</div> </div>
</div>
<div <div
id="header-slider-pagination-child" id="header-slider-pagination-child"
class="flex items-center justify-between" class="flex items-center justify-between"
> >
<button @click="swiper_instance?.slidePrev()"> <button @click="swiper_instance?.slidePrev()">
<Icon <Icon
class="**:stroke-white cursor-pointer size-5 md:size-6" class="**:stroke-white cursor-pointer size-5 md:size-6"
name="ci:arrow-right" name="ci:arrow-right"
/> />
</button> </button>
<div class="flex items-center justify-center gap-3 text-white"> <div class="flex items-center justify-center gap-3 text-white">
<div <div
v-for="(_slide, index) in homeData!.sliders" v-for="(_slide, index) in homeData!.sliders"
:class="swiper_instance?.realIndex === index ? 'bg-white' : 'bg-transparent'" :class="swiper_instance?.realIndex === index ? 'bg-white' : 'bg-transparent'"
class="border border-white size-2 md:size-3 rounded-full transition-all duration-200" class="border border-white size-2 md:size-3 rounded-full transition-all duration-200"
> >
</div>
</div> </div>
<button>
<Icon
@click="swiper_instance?.slideNext()"
class="**:stroke-white cursor-pointer size-5 md:size-6"
name="ci:arrow-left"
/>
</button>
</div> </div>
<button>
<Icon
@click="swiper_instance?.slideNext()"
class="**:stroke-white cursor-pointer size-5 md:size-6"
name="ci:arrow-left"
/>
</button>
</div> </div>
</div> </div>
</div> </div>
</SwiperSlide> </SwiperSlide>
</Swiper> </Swiper>
+1 -1
View File
@@ -16,7 +16,7 @@ await suspense();
<template> <template>
<section class="mt-20 container"> <section class="mt-20 container">
<div class="flex items-center justify-between mb-20"> <div class="flex items-center justify-between mb-20">
<span class="typo-h-6 md:typo-h-5 lg:typo-h-4 text-black"> <span class="typo-h-6 max-sm:text-xl md:typo-h-5 lg:typo-h-4 text-black">
مقالات اخیر سایت مقالات اخیر سایت
</span> </span>
<NuxtLink to="/articles"> <NuxtLink to="/articles">
+1 -1
View File
@@ -37,7 +37,7 @@ watch(
(newValue) => { (newValue) => {
const clientRect = previewContainerEl.value?.getBoundingClientRect()!; const clientRect = previewContainerEl.value?.getBoundingClientRect()!;
const percent = clientRect.width / 100; const percent = clientRect.width / 100;
const clipPercent = (newValue - clientRect.x - 8) / percent; const clipPercent = ((newValue + draggableEl.value!.clientWidth / 2) - clientRect.x - 8) / percent;
if (clipPercent >= 5 && clipPercent <= 95) { if (clipPercent >= 5 && clipPercent <= 95) {
clipPathPercent.value = clipPercent; clipPathPercent.value = clipPercent;
} }
@@ -11,6 +11,7 @@ const { data : homeData } = useHomeData();
const { $gsap: gsap, $ScrollTrigger: ScrollTrigger } = useNuxtApp(); const { $gsap: gsap, $ScrollTrigger: ScrollTrigger } = useNuxtApp();
let gsapTimeline: gsap.core.Timeline; let gsapTimeline: gsap.core.Timeline;
let scrollTrigger: ScrollTrigger;
// lifecycle // lifecycle
@@ -67,7 +68,7 @@ onMounted(() => {
filter: "invert(0%)" filter: "invert(0%)"
}); });
ScrollTrigger.create({ scrollTrigger = ScrollTrigger.create({
trigger: "#products-showcase-container", trigger: "#products-showcase-container",
animation: gsapTimeline, animation: gsapTimeline,
scrub: 1, scrub: 1,
@@ -77,6 +78,10 @@ onMounted(() => {
end: "bottom top" end: "bottom top"
}); });
setTimeout(() => {
scrollTrigger.refresh()
}, 1000);
}); });
onUnmounted(() => { onUnmounted(() => {
@@ -89,7 +94,7 @@ onUnmounted(() => {
<template> <template>
<div <div
id="products-showcase-container" id="products-showcase-container"
class="mt-80 mb-40 perspective-midrange w-full h-[125svh] bg-black flex items-center justify-center" class="mb-40 perspective-midrange w-full h-[125svh] bg-black flex items-center justify-center"
> >
<NuxtLink <NuxtLink
v-for="slide in homeData!.show_case_slider" v-for="slide in homeData!.show_case_slider"
+2 -1
View File
@@ -2,6 +2,7 @@
import { useQuery } from "@tanstack/vue-query"; import { useQuery } from "@tanstack/vue-query";
import { API_ENDPOINTS, QUERY_KEYS } from "~/constants"; import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
import type { GetArticleResponse } from "~/composables/api/blog/useGetArticle";
// types // types
@@ -15,7 +16,7 @@ export type GetHomeDataResponse = {
"video": string | null "video": string | null
}[], }[],
"sub_categories": SubCategory[], "sub_categories": SubCategory[],
"products": Product[], "products": ProductListItem[],
"difreance_section": { "difreance_section": {
"image1": string, "image1": string,
"image2": string, "image2": string,
+2 -2
View File
@@ -1,11 +1,11 @@
FROM node:20-alpine AS build-stage FROM node:20-alpine as build-stage
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install --force RUN npm install --force
COPY . . COPY . .
RUN npm run build RUN npm run build
FROM node:20-alpine AS production-stage FROM node:20-alpine as production-stage
WORKDIR /app WORKDIR /app
COPY --from=build-stage /app /app COPY --from=build-stage /app /app
EXPOSE 3000 EXPOSE 3000
-1
View File
@@ -31,7 +31,6 @@
"gsap": "^3.12.7", "gsap": "^3.12.7",
"isomorphic-dompurify": "^2.22.0", "isomorphic-dompurify": "^2.22.0",
"jalali-ts": "^8.0.0", "jalali-ts": "^8.0.0",
"masonry-layout": "^4.2.2",
"nuxt": "^3.15.4", "nuxt": "^3.15.4",
"reka-ui": "^1.0.0-alpha.6", "reka-ui": "^1.0.0-alpha.6",
"swiper": "^11.2.4", "swiper": "^11.2.4",
+4 -2
View File
@@ -3,10 +3,11 @@
// import // import
import useHomeData from "~/composables/api/home/useHomeData"; import useHomeData from "~/composables/api/home/useHomeData";
import ProductsGrid from "~/components/global/ProductsGrid.vue";
// state // state
const { suspense } = useHomeData(); const { data: homeData, suspense } = useHomeData();
// ssr // ssr
@@ -27,7 +28,8 @@ if (response.isError) {
<Hero /> <Hero />
<Preview /> <Preview />
<ProductsShowcase /> <ProductsShowcase />
<ProductsSlider title="محصولات پرفروش" /> <!-- <ProductsSlider title="محصولات پرفروش" />-->
<ProductsGrid title="محصولات پرفروش" :products="[...homeData!.products,...homeData!.products]"/>
<Categories /> <Categories />
<Brands /> <Brands />
<MostRecentComments /> <MostRecentComments />
+22 -41
View File
@@ -21,13 +21,12 @@ type LoginInfo = {
// meta // meta
definePageMeta({ definePageMeta({
layout: "none",
middleware: ["check-is-not-logged-in"] middleware: ["check-is-not-logged-in"]
}); });
// state // state
const { $gsap: gsap } = useNuxtApp();
const { addToast } = useToast(); const { addToast } = useToast();
const { updateToken, updateRefreshToken } = useAuth(); const { updateToken, updateRefreshToken } = useAuth();
@@ -150,21 +149,6 @@ const resetForm = () => {
showOtp.value = false; showOtp.value = false;
}; };
// const onMouseMove = (e: MouseEvent) => {
// const heylmzEyesElement = document.querySelector("#heylmz-eyes") as HTMLDivElement;
// const boundry = heylmzEyesElement.getBoundingClientRect();
//
// console.log(e.clientX, heylmzEyesElement.clientWidth, boundry.left, e.clientX - heylmzEyesElement.clientWidth);
//
// const moveX = e.clientX - heylmzEyesElement.clientWidth + 60;
// const moveY = e.clientY - heylmzEyesElement.clientHeight - 155;
//
// gsap.to("#heylmz-eyes", {
// x: moveX,
// y: moveY
// });
// };
</script> </script>
<template> <template>
@@ -179,8 +163,7 @@ const resetForm = () => {
<div class="flex items-center justify-center flex-col size-full"> <div class="flex items-center justify-center flex-col size-full">
<img <img
id="heylmz-eyes" class="aspect-square w-[300px] translate-y-[100px] animate-fade-in"
class="aspect-square w-[300px] translate-y-[100px]"
src="/img/heymlz-seat.gif" src="/img/heymlz-seat.gif"
alt="" alt=""
/> />
@@ -188,30 +171,28 @@ const resetForm = () => {
<div <div
class="max-w-[600px] w-full p-6 h-[400px] flex flex-col items-center bg-white border shadow-black/10 justify-center border-slate-300 rounded-xl" class="max-w-[600px] w-full p-6 h-[400px] flex flex-col items-center bg-white border shadow-black/10 justify-center border-slate-300 rounded-xl"
> >
<h1 class="typo-hero-2 mt-8">فرم ورود</h1> <h1 class="typo-h-5 mt-8">شماره خود را وارد کنید</h1>
<form @submit.prevent class="max-w-[500px] w-full mt-12"> <form @submit.prevent class="max-w-[500px] w-full mt-12">
<div v-if="!showOtp" class="flex items-center gap-2 w-full"> <Input
<Input v-if="!showOtp"
data-testid="phone-input" class="w-full tracking-[2px]"
class="w-full" v-model="loginInfo.phone"
v-model="loginInfo.phone" placeholder="9380123456"
placeholder="9380123456" dir="ltr"
dir="ltr" :error="formValidator$.phone.$error"
:error="formValidator$.phone.$error" >
> <template #startItem>
<template #startItem> <div class="flex items-center gap-3">
<span class="text-slate-500"> +98 </span> <Icon
</template> class="translate-y-[-1px] static-icon"
</Input> name="twemoji:flag-iran"
<div class="flex items-center gap-1"> size="24"
<Icon />
class="translate-y-[-1px]" <span class="text-slate-500 typo-label-sm"> +98 </span>
name="twemoji:flag-iran" </div>
size="24" </template>
/> </Input>
</div>
</div>
<OtpInput <OtpInput
v-else v-else
+1 -1
View File
@@ -5,7 +5,7 @@ precacheAndRoute(self.__WB_MANIFEST);
// Version // Version
const VERSION = "1.0.9112"; const VERSION = "1.0.2";
// Service Worker Installation // Service Worker Installation
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {