merge
This commit is contained in:
@@ -8,10 +8,11 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Copy files to server
|
- name: Copy files to server
|
||||||
uses: appleboy/scp-action@v0.1.6
|
uses: appleboy/scp-action@v0.1.6
|
||||||
@@ -21,8 +22,19 @@ jobs:
|
|||||||
password: ${{ secrets.SSH_PASSWORD }}
|
password: ${{ secrets.SSH_PASSWORD }}
|
||||||
source: "."
|
source: "."
|
||||||
target: "/root/hshop/"
|
target: "/root/hshop/"
|
||||||
|
rm: true
|
||||||
|
|
||||||
- name: SSH command to build and start Docker
|
- name: Deploy environment file
|
||||||
|
uses: appleboy/ssh-action@v0.1.6
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.SERVER_HOST }}
|
||||||
|
username: ${{ secrets.SSH_USER }}
|
||||||
|
password: ${{ secrets.SSH_PASSWORD }}
|
||||||
|
script: |
|
||||||
|
mkdir -p /root/hshop/backend/
|
||||||
|
printf "%s" "${{ secrets.ENV_FILE_CONTENT }}" > /root/hshop/backend/.env.local
|
||||||
|
|
||||||
|
- name: Build and start Docker containers
|
||||||
uses: appleboy/ssh-action@v0.1.6
|
uses: appleboy/ssh-action@v0.1.6
|
||||||
with:
|
with:
|
||||||
host: ${{ secrets.SERVER_HOST }}
|
host: ${{ secrets.SERVER_HOST }}
|
||||||
@@ -30,6 +42,11 @@ jobs:
|
|||||||
password: ${{ secrets.SSH_PASSWORD }}
|
password: ${{ secrets.SSH_PASSWORD }}
|
||||||
script: |
|
script: |
|
||||||
cd /root/hshop/
|
cd /root/hshop/
|
||||||
docker compose down
|
|
||||||
docker compose build
|
docker compose down --remove-orphans --timeout 60
|
||||||
docker compose up -d
|
|
||||||
|
docker compose up --build --detach --remove-orphans
|
||||||
|
|
||||||
|
docker image prune -af
|
||||||
|
|
||||||
|
docker compose ps
|
||||||
@@ -241,4 +241,11 @@ class SecurityBreachAttemptModel(models.Model):
|
|||||||
return f'تلاش نفوذ از {self.ip_address} در {self.city}, {self.country}'
|
return f'تلاش نفوذ از {self.ip_address} در {self.city}, {self.country}'
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "تلاش نفوذ"
|
verbose_name = "تلاش نفوذ"
|
||||||
verbose_name_plural = "تلاشهای نفوذ"
|
verbose_name_plural = "تلاشهای نفوذ"
|
||||||
|
|
||||||
|
# class NotifModel(models.Model):
|
||||||
|
# subject = models.CharField(max_length=100)
|
||||||
|
# description = models.TextField()
|
||||||
|
|
||||||
|
# def __str__(self):
|
||||||
|
# return f'{self.subject[:30]}'
|
||||||
@@ -2,6 +2,8 @@ from django.urls import path
|
|||||||
from . import views
|
from . import views
|
||||||
from djoser.urls.jwt import views as djoser_jwt_views
|
from djoser.urls.jwt import views as djoser_jwt_views
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('profile', views.ProfileView.as_view()),
|
path('profile', views.ProfileView.as_view()),
|
||||||
path('verify', djoser_jwt_views.TokenVerifyView.as_view(), name='jwt-verify'),
|
path('verify', djoser_jwt_views.TokenVerifyView.as_view(), name='jwt-verify'),
|
||||||
@@ -13,6 +15,7 @@ urlpatterns = [
|
|||||||
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('unsubscribe', views.UnsubscribeView.as_view(), name='unsubscibe'),
|
||||||
path('attack/view/<int:pk>', views.ChangeViewAttack.as_view(), name='attack-view'),
|
path('attack/view/<int:pk>', views.ChangeViewAttack.as_view(), name='attack-view'),
|
||||||
path('logout', views.LogoutView.as_view(), name='logout'),
|
path('logout', views.LogoutView.as_view(), name='logout'),
|
||||||
]
|
]
|
||||||
@@ -11,12 +11,11 @@ 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
|
from django.views import View
|
||||||
# this works only need to be used
|
from rest_framework import serializers
|
||||||
# class APIView(APIView):
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
# def __init__(self, *args, **kwargs):
|
|
||||||
# super().__init__(*args, **kwargs)
|
|
||||||
# if AllowAny in self.permission_classes or not self.permission_classes:
|
|
||||||
# self.authentication_classes = []
|
|
||||||
class SendOTPView(APIView):
|
class SendOTPView(APIView):
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
@@ -143,7 +142,9 @@ class CreateAddressView(generics.CreateAPIView):
|
|||||||
permission_classes = [permissions.IsAuthenticated]
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
serializer.save(user=self.request.user)
|
user = self.request.user
|
||||||
|
is_first_address = not UserAddressModel.objects.filter(user=user).exists()
|
||||||
|
serializer.save(user=user, is_main=is_first_address)
|
||||||
|
|
||||||
class EditAddressView(generics.UpdateAPIView):
|
class EditAddressView(generics.UpdateAPIView):
|
||||||
queryset = UserAddressModel.objects.all()
|
queryset = UserAddressModel.objects.all()
|
||||||
@@ -190,6 +191,25 @@ class SubscribeView(APIView):
|
|||||||
return Response(status=status.HTTP_400_BAD_REQUEST)
|
return Response(status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
|
class UnsubscribeSerializer(serializers.Serializer):
|
||||||
|
end_point = serializers.CharField()
|
||||||
|
|
||||||
|
class UnsubscribeView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
serializer_class = UnsubscribeSerializer
|
||||||
|
def post(self, request):
|
||||||
|
endpoint = request.data.get("end_point")
|
||||||
|
if not endpoint:
|
||||||
|
return Response({"detail": "اند پوینت لازم است"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
deleted, _ = PushSubscription.objects.filter(user=request.user, endpoint=endpoint).delete()
|
||||||
|
|
||||||
|
if deleted:
|
||||||
|
return Response({"detail": "با موفقیت اشتراک پاک شد"}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
return Response({"detail": "اند پوینت پیدا نشد"}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
|
||||||
class ChangeViewAttack(View):
|
class ChangeViewAttack(View):
|
||||||
def get(self, request, pk):
|
def get(self, request, pk):
|
||||||
attack = get_object_or_404(SecurityBreachAttemptModel, pk=pk)
|
attack = get_object_or_404(SecurityBreachAttemptModel, pk=pk)
|
||||||
@@ -198,8 +218,6 @@ class ChangeViewAttack(View):
|
|||||||
return redirect('admin:account_securitybreachattemptmodel_changelist')
|
return redirect('admin:account_securitybreachattemptmodel_changelist')
|
||||||
|
|
||||||
|
|
||||||
from rest_framework import serializers
|
|
||||||
from rest_framework_simplejwt.tokens import RefreshToken
|
|
||||||
|
|
||||||
class LogoutSerializer(serializers.Serializer):
|
class LogoutSerializer(serializers.Serializer):
|
||||||
refresh_token = serializers.CharField(help_text="Refresh token to be blacklisted")
|
refresh_token = serializers.CharField(help_text="Refresh token to be blacklisted")
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 5.1.2 on 2025-03-19 17:58
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('azbankgateways', '0006_bank_order'),
|
||||||
|
('order', '0023_remove_ordermodel_bank_records'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='bank',
|
||||||
|
name='order',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bank_records', to='order.ordermodel'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -143,7 +143,7 @@ USE_TZ = True
|
|||||||
# Static Files Configuration
|
# Static Files Configuration
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
STATICFILES_DIRS = [
|
STATICFILES_DIRS = [
|
||||||
os.path.join(BASE_DIR, "custom_static"),
|
# os.path.join(BASE_DIR, "custom_static"),
|
||||||
BASE_DIR / "core" / "static",
|
BASE_DIR / "core" / "static",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -5,3 +5,6 @@ class OrderConfig(AppConfig):
|
|||||||
default_auto_field = 'django.db.models.BigAutoField'
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
name = 'order'
|
name = 'order'
|
||||||
verbose_name = 'سفارش'
|
verbose_name = 'سفارش'
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
import order.signals
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
class DiscountNotAvailableError(Exception):
|
|
||||||
pass
|
|
||||||
@@ -2,7 +2,6 @@ from django.db import models
|
|||||||
from account.models import User, UserAddressModel, PushSubscription
|
from account.models import User, UserAddressModel, PushSubscription
|
||||||
from product.models import ProductModel, ProductVariant, ProductImageModel
|
from product.models import ProductModel, ProductVariant, ProductImageModel
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from .execptions import DiscountNotAvailableError
|
|
||||||
from django_jalali.db import models as jmodels
|
from django_jalali.db import models as jmodels
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,21 @@ class GetOrderPermission(BasePermission):
|
|||||||
def has_object_permission(self, request, view, obj):
|
def has_object_permission(self, request, view, obj):
|
||||||
if obj.user != request.user:
|
if obj.user != request.user:
|
||||||
return False
|
return False
|
||||||
if obj.status != 'CART':
|
if obj.status == 'CART':
|
||||||
self.message = "سفارش در وضعیت سبد خرید است"
|
self.message = "سفارش در وضعیت سبد خرید است"
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
from rest_framework.permissions import BasePermission
|
||||||
|
|
||||||
|
class SetAddressPermissions(BasePermission):
|
||||||
|
message = "این ادرس متعلق به شما نیست."
|
||||||
|
|
||||||
|
def has_object_permission(self, request, view, obj):
|
||||||
|
if obj.user != request.user:
|
||||||
|
self.message = "این ادرس متعلق به شما نیست."
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
from django.db.models.signals import pre_save
|
||||||
|
from django.dispatch import receiver
|
||||||
|
from .models import OrderModel
|
||||||
|
|
||||||
|
@receiver(pre_save, sender=OrderModel)
|
||||||
|
def order_status_changed(sender, instance, **kwargs):
|
||||||
|
if instance.pk:
|
||||||
|
previous = OrderModel.objects.get(pk=instance.pk)
|
||||||
|
|
||||||
|
if previous.status != instance.status:
|
||||||
|
send_change_status_notif(instance)
|
||||||
|
|
||||||
|
|
||||||
|
def send_change_status_notif(order):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def update_cart_price_fields(order):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def update_sell_data(order):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def update_quantity(order):
|
||||||
|
pass
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from .views import CartItemViews, CartView, OrderlistView, CartItemClear, ApplyDiscountView, OrderGetView
|
from .views import CartItemViews, CartView, OrderlistView, CartItemClear, ApplyDiscountView, OrderGetView, SetAddressForCartView
|
||||||
from .views import PaymentView, callback_view
|
from .views import PaymentView, callback_view
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('all', OrderlistView.as_view(), name='order-list'),
|
path('all', OrderlistView.as_view(), name='order-list'),
|
||||||
path('cart', CartView.as_view()),
|
path('cart', CartView.as_view()),
|
||||||
|
path('cart/set-address', SetAddressForCartView.as_view()),
|
||||||
path('cart/discount', ApplyDiscountView.as_view()),
|
path('cart/discount', ApplyDiscountView.as_view()),
|
||||||
path('cart/all', CartItemClear.as_view()),
|
path('cart/all', CartItemClear.as_view()),
|
||||||
path('cart/item/<int:pk>', CartItemViews.as_view(), name='change-item-cart'),
|
path('cart/item/<int:pk>', CartItemViews.as_view(), name='change-item-cart'),
|
||||||
path('payment', PaymentView.as_view(), name='payment'),
|
path('cart/payment', PaymentView.as_view(), name='payment'),
|
||||||
path('callback', callback_view, name='callback-gateway'),
|
path('callback', callback_view, name='callback-gateway'),
|
||||||
path('<int:pk>', OrderGetView.as_view(), name='order-get'),
|
path('<int:pk>', OrderGetView.as_view(), name='order-get'),
|
||||||
]
|
]
|
||||||
|
|||||||
+45
-13
@@ -1,5 +1,4 @@
|
|||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from .execptions import DiscountNotAvailableError
|
|
||||||
from rest_framework.views import APIView, Response
|
from rest_framework.views import APIView, Response
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from product.models import ProductVariant
|
from product.models import ProductVariant
|
||||||
@@ -8,25 +7,20 @@ from .serializers import *
|
|||||||
# from cart.models import
|
# from cart.models import
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from .models import OrderItemModel, OrderModel, DiscountCode
|
from .models import OrderItemModel, OrderModel, DiscountCode
|
||||||
from .permissons import CanDeleteCartItemPermissions, GetOrderPermission
|
from .permissons import CanDeleteCartItemPermissions, GetOrderPermission, SetAddressPermissions
|
||||||
from azbankgateways import bankfactories, models as bank_models
|
from azbankgateways import bankfactories, models as bank_models
|
||||||
from azbankgateways.exceptions import AZBankGatewaysException
|
from azbankgateways.exceptions import AZBankGatewaysException
|
||||||
from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes
|
from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes
|
||||||
from utils.pagination import StructurePagination
|
from utils.pagination import StructurePagination
|
||||||
from order.models import OrderModel
|
from order.models import OrderModel
|
||||||
try:
|
|
||||||
pass
|
|
||||||
except DiscountNotAvailableError:
|
|
||||||
pass
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
"""
|
from account.models import UserAddressModel
|
||||||
|
|
||||||
add post
|
|
||||||
remove delete
|
|
||||||
show get
|
|
||||||
|
|
||||||
pay
|
# try:
|
||||||
"""
|
# pass
|
||||||
|
# except DiscountNotAvailableError:
|
||||||
|
# pass
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -185,9 +179,22 @@ class OrderGetView(APIView):
|
|||||||
order_ser = self.serializer_class(order_object, context={'request': request})
|
order_ser = self.serializer_class(order_object, context={'request': request})
|
||||||
return Response(order_ser.data, status=status.HTTP_200_OK)
|
return Response(order_ser.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
class BankTypeSerializer(serializers.Serializer):
|
||||||
|
gateway_type = serializers.ChoiceField(choices=['BMI', 'SEP', 'ZARINPAL', 'IDPAY', 'ZIBAL', 'BAHAMTA', 'MELLAT', 'PAYV1'])
|
||||||
|
|
||||||
|
|
||||||
class PaymentView(APIView):
|
class PaymentView(APIView):
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
|
serializer_class = BankTypeSerializer
|
||||||
|
@extend_schema(
|
||||||
|
description="choices=['BMI', 'SEP', 'ZARINPAL', 'IDPAY', 'ZIBAL', 'BAHAMTA', 'MELLAT', 'PAYV1']"
|
||||||
|
)
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
|
print(request.data.get('gateway_type'))
|
||||||
cart_order = get_object_or_404(OrderModel, user=request.user, status='CART')
|
cart_order = get_object_or_404(OrderModel, user=request.user, status='CART')
|
||||||
amount = 5000
|
amount = 5000
|
||||||
user_mobile_number = request.user.phone
|
user_mobile_number = request.user.phone
|
||||||
@@ -245,4 +252,29 @@ def callback_view(request):
|
|||||||
|
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
"پرداخت با شکست مواجه شده است. اگر پول کم شده است ظرف مدت ۴۸ ساعت پول به حساب شما بازخواهد گشت."
|
"پرداخت با شکست مواجه شده است. اگر پول کم شده است ظرف مدت ۴۸ ساعت پول به حساب شما بازخواهد گشت."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class SetAddressSerilizer(serializers.Serializer):
|
||||||
|
address_id = serializers.IntegerField()
|
||||||
|
|
||||||
|
class SetAddressForCartView(APIView):
|
||||||
|
serializer_class = SetAddressSerilizer
|
||||||
|
permission_classes = [IsAuthenticated, SetAddressPermissions]
|
||||||
|
def post(self, request):
|
||||||
|
address_id = request.data.get('address_id', None)
|
||||||
|
if not address_id:
|
||||||
|
return Response({'detail': 'address_id را ارسال کنید'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
address_object = get_object_or_404(UserAddressModel, pk=address_id)
|
||||||
|
permission = SetAddressPermissions()
|
||||||
|
if not permission.has_object_permission(request, self, address_object):
|
||||||
|
return Response({"detail": permission.message}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
cart_order, created = OrderModel.objects.get_or_create(
|
||||||
|
user=request.user,
|
||||||
|
status='CART'
|
||||||
|
)
|
||||||
|
cart_order.address = address_object
|
||||||
|
cart_order.save()
|
||||||
|
return Response({'detail': 'ادرس با موفقیت انتخاب شد'})
|
||||||
@@ -50,6 +50,7 @@ class ProductVariantSerialzier(serializers.ModelSerializer):
|
|||||||
images = ProductImageSerailizer(many=True)
|
images = ProductImageSerailizer(many=True)
|
||||||
details = ProductDetailSerializer(many=True, read_only=True)
|
details = ProductDetailSerializer(many=True, read_only=True)
|
||||||
cart_quantity = serializers.SerializerMethodField()
|
cart_quantity = serializers.SerializerMethodField()
|
||||||
|
price = serializers.SerializerMethodField()
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ProductVariant
|
model = ProductVariant
|
||||||
exclude = ('min_price', 'sell', 'currency', 'product', 'input_price')
|
exclude = ('min_price', 'sell', 'currency', 'product', 'input_price')
|
||||||
@@ -72,6 +73,8 @@ class ProductVariantSerialzier(serializers.ModelSerializer):
|
|||||||
return item['quantity']
|
return item['quantity']
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
def get_pirce(self, obj):
|
||||||
|
return f'{obj.price:,.0f} تومان'
|
||||||
|
|
||||||
|
|
||||||
class SubCategorySerializer(serializers.ModelSerializer):
|
class SubCategorySerializer(serializers.ModelSerializer):
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const { isLoading } = useImage({ src: src.value });
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AvatarRoot
|
<AvatarRoot
|
||||||
class="flex-center size-full select-none rounded-full align-middle overflow-hidden"
|
class="flex-center size-full select-none rounded-full align-middle overflow-hidden inset-shadow-black/20 inset-shadow-sm"
|
||||||
>
|
>
|
||||||
<Skeleton
|
<Skeleton
|
||||||
v-if="isLoading"
|
v-if="isLoading"
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const isHomePage = computed(() => route.path === "/");
|
|||||||
<div
|
<div
|
||||||
class="size-full flex items-center justify-between container h-[65px] lg:h-[85px] shrink-0 grow-0"
|
class="size-full flex items-center justify-between container h-[65px] lg:h-[85px] shrink-0 grow-0"
|
||||||
>
|
>
|
||||||
<button class="md:hidden" @click="isSideDrawerOpen = true">
|
<button class="md:hidden header-navbar-item" @click="isSideDrawerOpen = true">
|
||||||
<Icon name="humbleicons:bars" size="28" />
|
<Icon name="humbleicons:bars" size="28" />
|
||||||
</button>
|
</button>
|
||||||
<div class="max-md:hidden flex items-center gap-8 lg:gap-16">
|
<div class="max-md:hidden flex items-center gap-8 lg:gap-16">
|
||||||
@@ -42,10 +42,10 @@ const isHomePage = computed(() => route.path === "/");
|
|||||||
<Tooltip v-if="!!account && !!token" title="حساب کاربری">
|
<Tooltip v-if="!!account && !!token" title="حساب کاربری">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="{ name: 'profile' }"
|
:to="{ name: 'profile' }"
|
||||||
class="!size-[1.6rem] flex items-center justify-center relative overflow-hidden rounded-full border-[1.2px] border-black"
|
class="flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
class="!size-[1.6rem]"
|
class="!size-7"
|
||||||
:src="account.profile_photo"
|
:src="account.profile_photo"
|
||||||
:alt="
|
:alt="
|
||||||
account.first_name && account.last_name
|
account.first_name && account.last_name
|
||||||
@@ -66,7 +66,7 @@ const isHomePage = computed(() => route.path === "/");
|
|||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title="محصولات">
|
<Tooltip title="محصولات">
|
||||||
<NuxtLink to="/products" class="flex-center">
|
<NuxtLink to="/products" class="flex-center header-navbar-item">
|
||||||
<Icon
|
<Icon
|
||||||
name="ci:search"
|
name="ci:search"
|
||||||
class="**:stroke-black size-4.5 lg:size-[21px]"
|
class="**:stroke-black size-4.5 lg:size-[21px]"
|
||||||
@@ -78,22 +78,20 @@ const isHomePage = computed(() => route.path === "/");
|
|||||||
<button class="relative">
|
<button class="relative">
|
||||||
<Icon
|
<Icon
|
||||||
name="ci:cart"
|
name="ci:cart"
|
||||||
class="**:stroke-black size-5 lg:size-6"
|
class="**:stroke-black size-5 lg:size-6 header-navbar-item"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span
|
<div
|
||||||
v-if="cart?.items.length! > 0"
|
v-if="cart?.items.length! > 0"
|
||||||
class="size-4 shrink-0 absolute -bottom-2 -right-2 flex-center rounded-sm text-white bg-red-600 text-xs animate-pulse"
|
class="size-2 shrink-0 absolute -bottom-1.5 -right-1.5 rounded-full bg-red-600 after:size-[125%] after:absolute after:bg-red-600 flex-center after:rounded-full after:animate-ping"
|
||||||
>
|
/>
|
||||||
{{ cart?.items.length }}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
class="flex-center gap-6 lg:gap-[2.5rem] typo-label-xs lg:typo-label-sm font-light text-black/80"
|
class="flex-center gap-6 lg:gap-[2.5rem] typo-label-xs lg:typo-label-sm font-light text-black/80 header-navbar-item"
|
||||||
>
|
>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-for="(link, index) in NAV_LINKS"
|
v-for="(link, index) in NAV_LINKS"
|
||||||
@@ -105,10 +103,10 @@ const isHomePage = computed(() => route.path === "/");
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div class="header-navbar-item">
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="h-5 lg:h-6"
|
class="h-5 lg:h-6 "
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 220 40"
|
viewBox="0 0 220 40"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,29 +3,33 @@
|
|||||||
// state
|
// state
|
||||||
|
|
||||||
const { $gsap: gsap } = useNuxtApp();
|
const { $gsap: gsap } = useNuxtApp();
|
||||||
|
const showLoadingOverlay = useState("showLoadingOverlay");
|
||||||
|
const disableLoadingOverlay = useState("disableLoadingOverlay");
|
||||||
|
const shouldRenderLoadingOverlay = ref(true);
|
||||||
|
|
||||||
const showLoadingOverlay = useState('showLoadingOverlay');
|
const isWindowScrollLocked = useScrollLock(window);
|
||||||
|
|
||||||
// lifecycle
|
// watch
|
||||||
|
|
||||||
watch(() => showLoadingOverlay.value, (value) => {
|
watch(() => showLoadingOverlay.value, (value) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
const timeline = gsap.timeline();
|
const timeline = gsap.timeline();
|
||||||
|
|
||||||
|
const imageElement = document.querySelector("#loading-overlay-image") as HTMLImageElement;
|
||||||
|
|
||||||
|
imageElement.src = "/img/heymlz-loading-2.gif";
|
||||||
|
|
||||||
timeline
|
timeline
|
||||||
.to("#loading-overlay", {
|
.to("#loading-overlay", {
|
||||||
scale: 1
|
scale: 1
|
||||||
})
|
})
|
||||||
.to("#loading-overlay", {
|
.to("#loading-overlay", {
|
||||||
scale: 0.8,
|
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
delay: 2.5
|
delay: 2,
|
||||||
})
|
|
||||||
.to("#loading-overlay", {
|
|
||||||
opacity: 0,
|
|
||||||
y: "20%",
|
|
||||||
onComplete: () => {
|
onComplete: () => {
|
||||||
shouldRenderLoadingOverlay.value = false;
|
shouldRenderLoadingOverlay.value = false;
|
||||||
|
isWindowScrollLocked.value = false;
|
||||||
|
disableLoadingOverlay.value = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -33,6 +37,16 @@ watch(() => showLoadingOverlay.value, (value) => {
|
|||||||
once: true
|
once: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// lifecycle
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
isWindowScrollLocked.value = true;
|
||||||
|
|
||||||
|
const newImage = new Image();
|
||||||
|
newImage.src = "/img/heymlz-loading-2.gif";
|
||||||
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -41,7 +55,12 @@ watch(() => showLoadingOverlay.value, (value) => {
|
|||||||
id="loading-overlay"
|
id="loading-overlay"
|
||||||
class="fixed inset-0 size-full z-9999 flex-center bg-black"
|
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="" />
|
<img
|
||||||
|
id="loading-overlay-image"
|
||||||
|
src="/img/heymlz-loading-1.gif"
|
||||||
|
class="opacity-0 scale-70 absolute z-20"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
id="loading-overlay-gradient"
|
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]"
|
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]"
|
||||||
|
|||||||
@@ -49,10 +49,10 @@ const closeSideDrawer = () => {
|
|||||||
<Tooltip v-if="!!account && !!token" title="حساب کاربری">
|
<Tooltip v-if="!!account && !!token" title="حساب کاربری">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="{ name: 'profile' }"
|
:to="{ name: 'profile' }"
|
||||||
class="!size-[1.6rem] flex items-center justify-center relative overflow-hidden rounded-full border-[1.2px] border-black"
|
class="flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
class="!size-[1.6rem]"
|
class="!size-7"
|
||||||
:src="account.profile_photo"
|
:src="account.profile_photo"
|
||||||
:alt="
|
:alt="
|
||||||
account.first_name && account.last_name
|
account.first_name && account.last_name
|
||||||
|
|||||||
@@ -51,9 +51,10 @@ const { data: account } = useGetAccount();
|
|||||||
<template #trigger>
|
<template #trigger>
|
||||||
<button
|
<button
|
||||||
v-if="!!account"
|
v-if="!!account"
|
||||||
class="size-[1.6rem] flex items-center justify-center relative overflow-hidden rounded-full border border-black"
|
class="flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
|
class="!size-7"
|
||||||
:src="account.profile_photo"
|
:src="account.profile_photo"
|
||||||
:alt="
|
:alt="
|
||||||
account.first_name && account.last_name
|
account.first_name && account.last_name
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
type Props = {
|
type Props = {
|
||||||
modelValue: number;
|
modelValue: number;
|
||||||
max: number;
|
max: number;
|
||||||
|
disable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// props
|
// props
|
||||||
@@ -48,6 +49,7 @@ const onInput = (e: any) => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<NumberFieldRoot
|
<NumberFieldRoot
|
||||||
|
:disabled="disable"
|
||||||
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"
|
||||||
@@ -58,7 +60,7 @@ const onInput = (e: any) => {
|
|||||||
</NumberFieldIncrement>
|
</NumberFieldIncrement>
|
||||||
<NumberFieldInput
|
<NumberFieldInput
|
||||||
@input="onInput"
|
@input="onInput"
|
||||||
class="field-sizing-content bg-transparent w-[30px] text-center outline-none typo-label-md text-black"
|
class="field-sizing-content w-[30px] bg-transparent text-center 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" />
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
// import
|
||||||
|
|
||||||
|
import useGetProduct from "~/composables/api/product/useGetProduct";
|
||||||
|
import useAddCartItem from "~/composables/api/orders/useAddCartItem";
|
||||||
|
import type { ProductVariantProvideType } from "~/pages/product/[id].vue";
|
||||||
|
|
||||||
|
// provide / inject
|
||||||
|
|
||||||
|
const { selectedVariant } = inject(
|
||||||
|
"productVariant"
|
||||||
|
) as ProductVariantProvideType;
|
||||||
|
|
||||||
|
// state
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const id = route.params.id as string | undefined;
|
||||||
|
|
||||||
|
const { refetch: refetchProduct } = useGetProduct(id);
|
||||||
|
const { mutateAsync: addCartItem, isPending: isAddCartItemPending } = useAddCartItem();
|
||||||
|
|
||||||
|
const timer = ref<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const quantity = ref(1);
|
||||||
|
|
||||||
|
// methods
|
||||||
|
|
||||||
|
const onInput = (e: any) => {
|
||||||
|
const value = Number(e.target.value);
|
||||||
|
if (value > 0 && value <= selectedVariant.value!.in_stock) {
|
||||||
|
quantity.value = value;
|
||||||
|
} else {
|
||||||
|
quantity.value = 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => quantity.value,
|
||||||
|
(newValue) => {
|
||||||
|
if (timer.value) clearTimeout(timer.value);
|
||||||
|
timer.value = setTimeout(async () => {
|
||||||
|
await addCartItem({ id: selectedVariant.value!.id, quantity: newValue });
|
||||||
|
await refetchProduct();
|
||||||
|
}, 350);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(selectedVariant, (newValue) => {
|
||||||
|
quantity.value = newValue!.cart_quantity;
|
||||||
|
});
|
||||||
|
|
||||||
|
// lifecycle
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
quantity.value = selectedVariant.value!.cart_quantity;
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NumberFieldRoot
|
||||||
|
class="rounded-full border-slate-200 border-[1.5px] flex items-center bg-white gap-4 p-4"
|
||||||
|
v-model="quantity"
|
||||||
|
:min="1"
|
||||||
|
:max="selectedVariant!.in_stock"
|
||||||
|
>
|
||||||
|
<NumberFieldIncrement class="cursor-pointer">
|
||||||
|
<Icon name="ci:plus" class="**:stroke-slate-500 size-5" />
|
||||||
|
</NumberFieldIncrement>
|
||||||
|
<div class="relative">
|
||||||
|
<div
|
||||||
|
:class="isAddCartItemPending ? 'opacity-100' : 'opacity-0'"
|
||||||
|
class="w-[40px] h-[25px] flex-center transition-all absolute bg-white"
|
||||||
|
>
|
||||||
|
<Icon :name="'svg-spinners:180-ring-with-bg'" class="size-[25px]" />
|
||||||
|
</div>
|
||||||
|
<NumberFieldInput
|
||||||
|
@input="onInput"
|
||||||
|
:class="!isAddCartItemPending ? 'opacity-100' : 'opacity-0'"
|
||||||
|
class="transition-all field-sizing-content w-[40px] h-[25px] bg-transparent text-center outline-none typo-label-md text-black"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<NumberFieldDecrement class="cursor-pointer">
|
||||||
|
<Icon name="ci:minus" class="**:stroke-slate-500 size-5" />
|
||||||
|
</NumberFieldDecrement>
|
||||||
|
</NumberFieldRoot>
|
||||||
|
</template>
|
||||||
@@ -32,7 +32,9 @@ let scrollTrigger: ScrollTrigger;
|
|||||||
// methods
|
// methods
|
||||||
|
|
||||||
const onSwiper = (swiper: SwiperClass) => {
|
const onSwiper = (swiper: SwiperClass) => {
|
||||||
showLoadingOverlay.value = false;
|
setTimeout(() => {
|
||||||
|
showLoadingOverlay.value = false;
|
||||||
|
}, 1000);
|
||||||
swiper_instance.value = swiper;
|
swiper_instance.value = swiper;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,11 +72,9 @@ const initializeGsapAnimation = () => {
|
|||||||
}, {
|
}, {
|
||||||
value: 1.2
|
value: 1.2
|
||||||
}, "=")
|
}, "=")
|
||||||
.fromTo("#header-navbar", {
|
.fromTo(".header-navbar-item", {
|
||||||
background: "transparent",
|
|
||||||
filter: "invert(100%)"
|
filter: "invert(100%)"
|
||||||
}, {
|
}, {
|
||||||
background: "transparent",
|
|
||||||
filter: "invert(0%)"
|
filter: "invert(0%)"
|
||||||
}, "=")
|
}, "=")
|
||||||
.fromTo("#header-navbar", {
|
.fromTo("#header-navbar", {
|
||||||
@@ -96,7 +96,9 @@ const initializeGsapAnimation = () => {
|
|||||||
|
|
||||||
const resetTimelineForMobile = () => {
|
const resetTimelineForMobile = () => {
|
||||||
gsap.to("#header-navbar", {
|
gsap.to("#header-navbar", {
|
||||||
background: "white",
|
background: "white"
|
||||||
|
});
|
||||||
|
gsap.to(".header-navbar-item", {
|
||||||
filter: "invert(0%)"
|
filter: "invert(0%)"
|
||||||
});
|
});
|
||||||
gsap.set(".header-slider-item", {
|
gsap.set(".header-slider-item", {
|
||||||
@@ -113,6 +115,7 @@ onMounted(() => {
|
|||||||
initializeGsapAnimation();
|
initializeGsapAnimation();
|
||||||
|
|
||||||
scrollTrigger = ScrollTrigger.create({
|
scrollTrigger = ScrollTrigger.create({
|
||||||
|
anticipatePin: 1,
|
||||||
trigger: "#header-slider-container",
|
trigger: "#header-slider-container",
|
||||||
animation: gsapTimeline,
|
animation: gsapTimeline,
|
||||||
scrub: 1,
|
scrub: 1,
|
||||||
@@ -124,27 +127,23 @@ onMounted(() => {
|
|||||||
|
|
||||||
const calculateOnResize = () => {
|
const calculateOnResize = () => {
|
||||||
if (window.innerWidth > 768) {
|
if (window.innerWidth > 768) {
|
||||||
gsap.to("#header-navbar", {
|
|
||||||
background: "transparent",
|
|
||||||
filter: "invert(100%)"
|
|
||||||
});
|
|
||||||
scrollTrigger.enable();
|
scrollTrigger.enable();
|
||||||
} else {
|
} else {
|
||||||
resetTimelineForMobile();
|
resetTimelineForMobile();
|
||||||
scrollTrigger.disable();
|
scrollTrigger.disable();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
calculateOnResize()
|
calculateOnResize();
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
calculateOnResize()
|
calculateOnResize();
|
||||||
}, 200);
|
}, 200);
|
||||||
|
|
||||||
window.addEventListener("resize", () => {
|
window.addEventListener("resize", () => {
|
||||||
calculateOnResize()
|
calculateOnResize();
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
@@ -213,7 +212,8 @@ onUnmounted(() => {
|
|||||||
<div class="flex items-center gap-4 lg:gap-8">
|
<div class="flex items-center gap-4 lg:gap-8">
|
||||||
<div
|
<div
|
||||||
class="hover:scale-110 size-[36px] md:size-[42px] lg:size-[50px] relative flex items-center justify-center">
|
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" />
|
<div
|
||||||
|
class="size-full scale-75 bg-white absolute rounded-full animate-ping" />
|
||||||
<button
|
<button
|
||||||
@click="isMuted = !isMuted"
|
@click="isMuted = !isMuted"
|
||||||
class="transition-all cursor-pointer flex-center bg-white z-10 size-full rounded-full"
|
class="transition-all cursor-pointer flex-center bg-white z-10 size-full rounded-full"
|
||||||
@@ -232,11 +232,13 @@ onUnmounted(() => {
|
|||||||
<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
|
<NuxtLink :to="slide.link">
|
||||||
class="max-sm:hidden max-lg:typo-label-xs invert rounded-full hover:bg-transparent"
|
<Button
|
||||||
>
|
class="max-sm:hidden max-lg:typo-label-xs invert rounded-full hover:bg-transparent"
|
||||||
مشاهده
|
>
|
||||||
</Button>
|
مشاهده
|
||||||
|
</Button>
|
||||||
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import useHomeData from "~/composables/api/home/useHomeData";
|
|||||||
|
|
||||||
// state
|
// state
|
||||||
|
|
||||||
const { data : homeData } = useHomeData();
|
const { data: homeData } = useHomeData();
|
||||||
|
|
||||||
const { $gsap: gsap, $ScrollTrigger: ScrollTrigger } = useNuxtApp();
|
const { $gsap: gsap, $ScrollTrigger: ScrollTrigger } = useNuxtApp();
|
||||||
|
|
||||||
@@ -17,71 +17,60 @@ let scrollTrigger: ScrollTrigger;
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
gsapTimeline = gsap.timeline();
|
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");
|
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 = ScrollTrigger.create({
|
|
||||||
trigger: "#products-showcase-container",
|
|
||||||
animation: gsapTimeline,
|
|
||||||
scrub: 1,
|
|
||||||
pin: true,
|
|
||||||
start: "top top",
|
|
||||||
// markers: true,
|
|
||||||
end: "bottom top"
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
scrollTrigger.refresh()
|
showcaseElements.forEach((element, index) => {
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
|
gsapTimeline.fromTo(element, index === 0 ? {
|
||||||
|
opacity: 1,
|
||||||
|
scale: 1,
|
||||||
|
// rotateX: -25,
|
||||||
|
top: 0,
|
||||||
|
ease: "none"
|
||||||
|
} : {
|
||||||
|
opacity: 0,
|
||||||
|
scale: 1,
|
||||||
|
// rotateX: -25,
|
||||||
|
top: 20,
|
||||||
|
ease: "none"
|
||||||
|
}, {
|
||||||
|
opacity: 1,
|
||||||
|
scale: 1,
|
||||||
|
// rotateX: 0,
|
||||||
|
top: 0,
|
||||||
|
ease: "none"
|
||||||
|
}, index === 0 ? "-=0%" : undefined);
|
||||||
|
|
||||||
|
if (index < showcaseElements.length - 1) {
|
||||||
|
gsapTimeline.to(element, {
|
||||||
|
opacity: 0,
|
||||||
|
scale: 1.03,
|
||||||
|
// rotateX: 25,
|
||||||
|
top: -20,
|
||||||
|
ease: "none"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
scrollTrigger = ScrollTrigger.create({
|
||||||
|
trigger: "#products-showcase-container",
|
||||||
|
animation: gsapTimeline,
|
||||||
|
scrub: 1,
|
||||||
|
pin: true,
|
||||||
|
start: "top top",
|
||||||
|
anticipatePin: 1,
|
||||||
|
// markers: true,
|
||||||
|
end: "bottom top"
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
scrollTrigger.update();
|
||||||
|
scrollTrigger.refresh();
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
}, 1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -92,33 +81,35 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<section
|
||||||
id="products-showcase-container"
|
id="products-showcase-container"
|
||||||
class="perspective-midrange w-full h-[125svh] bg-black flex items-center justify-center"
|
class="perspective-midrange relative z-[99999]"
|
||||||
>
|
>
|
||||||
<NuxtLink
|
<div class="w-full h-[125svh] bg-black">
|
||||||
v-for="slide in homeData!.show_case_slider"
|
<NuxtLink
|
||||||
:key="slide.id"
|
v-for="slide in homeData!.show_case_slider"
|
||||||
:to="slide.link"
|
:key="slide.id"
|
||||||
class="showcase-slide origin-bottom absolute size-full bg-transparent flex items-center justify-center"
|
:to="slide.link"
|
||||||
>
|
class="showcase-slide origin-bottom absolute size-full bg-transparent flex items-center justify-center"
|
||||||
|
>
|
||||||
|
|
||||||
<img
|
<img
|
||||||
class="w-[280px] xs:w-[350px] lg:w-[500px] xl:w-[650px] z-20 mb-30 sm:mb-20 lg:mb-30"
|
class="w-[280px] xs:w-[350px] lg:w-[500px] xl:w-[650px] z-20 mb-30 sm:mb-20 lg:mb-30"
|
||||||
:src="slide.image"
|
:src="slide.image"
|
||||||
:style="{
|
:style="{
|
||||||
mask: 'linear-gradient(to bottom, black 0%, rgba(0,0,0,0) 100%)',
|
mask: 'linear-gradient(to bottom, black 0%, rgba(0,0,0,0) 100%)',
|
||||||
}"
|
}"
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-col items-center justify-center gap-4 text-center absolute z-20 mt-20">
|
<div class="flex flex-col items-center justify-center gap-4 text-center absolute z-20 mt-20">
|
||||||
<span class="text-white typo-h-6 sm:typo-h-5 lg:typo-h-4 xl:typo-h-3">
|
<span class="text-white typo-h-6 sm:typo-h-5 lg:typo-h-4 xl:typo-h-3">
|
||||||
{{ slide.title }}
|
{{ slide.title }}
|
||||||
</span>
|
</span>
|
||||||
<p class="text-white max-w-[320px] xs:max-w-[360px] sm:max-w-[480px] lg:max-w-[550px] xl:max-w-[750px] typo-p-sm lg:typo-p-md xl:typo-p-lg">
|
<p class="text-white max-w-[320px] xs:max-w-[360px] sm:max-w-[480px] lg:max-w-[550px] xl:max-w-[750px] typo-p-sm lg:typo-p-md xl:typo-p-lg">
|
||||||
{{ slide.description }}
|
{{ slide.description }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
// provide / inject
|
// provide / inject
|
||||||
|
|
||||||
import type { ProductVariantProvideType } from "~/pages/product/types";
|
import type { ProductVariantProvideType } from "~/pages/product";
|
||||||
|
|
||||||
const { selectedVariant } = inject("productVariant") as ProductVariantProvideType;
|
const { selectedVariant } = inject("productVariant") as ProductVariantProvideType;
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
// import
|
// import
|
||||||
|
|
||||||
import useGetProduct from "~/composables/api/product/useGetProduct";
|
import useGetProduct from "~/composables/api/product/useGetProduct";
|
||||||
import { sanitize } from "isomorphic-dompurify";
|
|
||||||
import type { ProductVariantProvideType } from "~/pages/product/[id].vue";
|
import type { ProductVariantProvideType } from "~/pages/product/[id].vue";
|
||||||
import useAddCartItem from "~/composables/api/orders/useAddCartItem";
|
import useAddCartItem from "~/composables/api/orders/useAddCartItem";
|
||||||
import { useAuth } from "~/composables/api/auth/useAuth";
|
import { useAuth } from "~/composables/api/auth/useAuth";
|
||||||
@@ -37,12 +36,6 @@ const addItemToCart = async () => {
|
|||||||
await refetchProduct();
|
await refetchProduct();
|
||||||
};
|
};
|
||||||
|
|
||||||
// computed
|
|
||||||
|
|
||||||
const sanitizedProductDescription = computed(() => {
|
|
||||||
return sanitize(product.value!.description);
|
|
||||||
});
|
|
||||||
|
|
||||||
// watch
|
// watch
|
||||||
|
|
||||||
watch(() => selectedVariantId.value, (newId) => {
|
watch(() => selectedVariantId.value, (newId) => {
|
||||||
@@ -147,7 +140,7 @@ watch(() => selectedVariant.value!, (newValue) => {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
class="py-8 typo-p-md text-slate-500 text-justify [&_a]:text-blue-400 [&_strong]:font-bold [&_u]:text-red-400"
|
class="py-8 typo-p-md text-slate-500 text-justify [&_a]:text-blue-400 [&_strong]:font-bold [&_u]:text-red-400"
|
||||||
v-html="sanitizedProductDescription"
|
v-html="product!.description"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ await suspense();
|
|||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
<Avatar
|
<Avatar
|
||||||
class="!size-[3rem]"
|
class="!size-12"
|
||||||
:src="account!.profile_photo"
|
:src="account!.profile_photo"
|
||||||
:alt="
|
:alt="
|
||||||
account?.first_name && account?.last_name
|
account?.first_name && account?.last_name
|
||||||
|
|||||||
@@ -134,15 +134,11 @@ onFileDialogChange((files: any) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full flex-col-center gap-5">
|
<div class="w-full flex-col-center gap-5">
|
||||||
<div
|
<Avatar
|
||||||
class="size-32 border border-slate-200 rounded-full"
|
:src="currentProfile"
|
||||||
>
|
alt=""
|
||||||
<Avatar
|
class="!size-32"
|
||||||
:src="currentProfile"
|
/>
|
||||||
:alt="''"
|
|
||||||
class="size-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
class="rounded-full w-[8rem]"
|
class="rounded-full w-[8rem]"
|
||||||
@click="openFileDialog"
|
@click="openFileDialog"
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
// import
|
||||||
|
|
||||||
|
import { usePersianTimeAgo } from "~/composables/global/usePersianTimeAgo";
|
||||||
|
|
||||||
|
// type
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
date: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// props
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const { date } = toRefs(props);
|
||||||
|
|
||||||
|
// state
|
||||||
|
|
||||||
|
const timeAgo = usePersianTimeAgo(new Date(date.value));
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="px-3 pb-1 pt-1.5 rounded-lg bg-neutral-600 text-white" dir="rtl">
|
||||||
|
{{ timeAgo }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -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 { sanitize } from "isomorphic-dompurify";
|
||||||
|
|
||||||
// types
|
// types
|
||||||
|
|
||||||
@@ -22,7 +23,15 @@ const useGetArticle = (id: number | string | undefined) => {
|
|||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: [QUERY_KEYS.article, id],
|
queryKey: [QUERY_KEYS.article, id],
|
||||||
queryFn: () => handleGetArticle()
|
queryFn: () => handleGetArticle(),
|
||||||
|
select: (article) => {
|
||||||
|
const copyOfArticle = { ...article };
|
||||||
|
|
||||||
|
copyOfArticle.summery = sanitize(copyOfArticle.summery);
|
||||||
|
copyOfArticle.content = sanitize(copyOfArticle.content);
|
||||||
|
|
||||||
|
return copyOfArticle;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 { sanitize } from "isomorphic-dompurify";
|
||||||
|
|
||||||
// types
|
// types
|
||||||
|
|
||||||
@@ -26,6 +27,8 @@ const useGetProduct = (id: string | number | undefined) => {
|
|||||||
select: (product) => {
|
select: (product) => {
|
||||||
const copyOfProduct = { ...product };
|
const copyOfProduct = { ...product };
|
||||||
|
|
||||||
|
copyOfProduct.description = sanitize(copyOfProduct.description);
|
||||||
|
|
||||||
copyOfProduct.variants = copyOfProduct.variants.sort((a, b) => b.in_stock - a.in_stock);
|
copyOfProduct.variants = copyOfProduct.variants.sort((a, b) => b.in_stock - a.in_stock);
|
||||||
|
|
||||||
return copyOfProduct;
|
return copyOfProduct;
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export default defineNuxtRouteMiddleware(() => {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
|
||||||
|
if (config.public.DEBUG === "true") {
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
return navigateTo("/");
|
||||||
|
}
|
||||||
|
});
|
||||||
+27
-26
@@ -6,45 +6,45 @@ export default defineNuxtConfig({
|
|||||||
css: [
|
css: [
|
||||||
"~/assets/css/tailwind.css",
|
"~/assets/css/tailwind.css",
|
||||||
"swiper/css",
|
"swiper/css",
|
||||||
"animate.css/animate.min.css",
|
"animate.css/animate.min.css"
|
||||||
],
|
],
|
||||||
|
|
||||||
routeRules: {
|
routeRules: {
|
||||||
"/products": { prerender: false, ssr: false },
|
"/products": { prerender: false, ssr: false }
|
||||||
},
|
},
|
||||||
|
|
||||||
app: {
|
app: {
|
||||||
head: {
|
head: {
|
||||||
title: "فروشگاه هی ملز",
|
title: "فروشگاه هی ملز"
|
||||||
},
|
},
|
||||||
pageTransition: {
|
pageTransition: {
|
||||||
enterActiveClass:
|
enterActiveClass:
|
||||||
"animate__animated animate__fadeIn animate__faster",
|
"animate__animated animate__fadeIn animate__faster",
|
||||||
leaveActiveClass:
|
leaveActiveClass:
|
||||||
"animate__animated animate__fadeOut animate__faster",
|
"animate__animated animate__fadeOut animate__faster",
|
||||||
mode: "out-in",
|
mode: "out-in"
|
||||||
},
|
},
|
||||||
layoutTransition: {
|
layoutTransition: {
|
||||||
enterActiveClass:
|
enterActiveClass:
|
||||||
"animate__animated animate__fadeIn animate__faster",
|
"animate__animated animate__fadeIn animate__faster",
|
||||||
leaveActiveClass:
|
leaveActiveClass:
|
||||||
"animate__animated animate__fadeOut animate__faster",
|
"animate__animated animate__fadeOut animate__faster",
|
||||||
mode: "out-in",
|
mode: "out-in"
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
postcss: {
|
postcss: {
|
||||||
plugins: {
|
plugins: {
|
||||||
"@tailwindcss/postcss": {},
|
"@tailwindcss/postcss": {},
|
||||||
autoprefixer: {},
|
autoprefixer: {}
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
components: [
|
components: [
|
||||||
{
|
{
|
||||||
path: "~/components",
|
path: "~/components",
|
||||||
pathPrefix: false,
|
pathPrefix: false
|
||||||
},
|
}
|
||||||
],
|
],
|
||||||
|
|
||||||
icon: {
|
icon: {
|
||||||
@@ -52,9 +52,9 @@ export default defineNuxtConfig({
|
|||||||
customCollections: [
|
customCollections: [
|
||||||
{
|
{
|
||||||
prefix: "ci",
|
prefix: "ci",
|
||||||
dir: "./public/icons",
|
dir: "./public/icons"
|
||||||
},
|
}
|
||||||
],
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
modules: [
|
modules: [
|
||||||
@@ -65,15 +65,15 @@ export default defineNuxtConfig({
|
|||||||
"DM Sans": "100..900",
|
"DM Sans": "100..900",
|
||||||
Inter: "100..900",
|
Inter: "100..900",
|
||||||
download: true,
|
download: true,
|
||||||
inject: false,
|
inject: false
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
],
|
],
|
||||||
"@nuxt/icon",
|
"@nuxt/icon",
|
||||||
"reka-ui/nuxt",
|
"reka-ui/nuxt",
|
||||||
"@vueuse/nuxt",
|
"@vueuse/nuxt",
|
||||||
"@formkit/auto-animate/nuxt",
|
"@formkit/auto-animate/nuxt",
|
||||||
"@vite-pwa/nuxt",
|
"@vite-pwa/nuxt"
|
||||||
],
|
],
|
||||||
|
|
||||||
pwa: {
|
pwa: {
|
||||||
@@ -88,26 +88,27 @@ export default defineNuxtConfig({
|
|||||||
{
|
{
|
||||||
src: "/logo/logo-192x192.png",
|
src: "/logo/logo-192x192.png",
|
||||||
sizes: "192x192",
|
sizes: "192x192",
|
||||||
type: "image/png",
|
type: "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: "/logo/logo-512x512.png",
|
src: "/logo/logo-512x512.png",
|
||||||
sizes: "512x512",
|
sizes: "512x512",
|
||||||
type: "image/png",
|
type: "image/png"
|
||||||
},
|
}
|
||||||
],
|
]
|
||||||
},
|
},
|
||||||
workbox: {
|
workbox: {
|
||||||
navigateFallback: "/",
|
navigateFallback: "/",
|
||||||
clientsClaim: true,
|
clientsClaim: true,
|
||||||
skipWaiting: true,
|
skipWaiting: true
|
||||||
},
|
},
|
||||||
devOptions: { enabled: true, type: "module" },
|
devOptions: { enabled: true, type: "module" }
|
||||||
},
|
},
|
||||||
|
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
public: {
|
public: {
|
||||||
API_BASE_URL: "https://api.heymlz.com",
|
API_BASE_URL: process.env.API_BASE_URL,
|
||||||
},
|
DEBUG: process.env.DEBUG
|
||||||
},
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"date-fns-jalali": "^4.1.0-0",
|
"date-fns-jalali": "^4.1.0-0",
|
||||||
"fast-average-color": "^9.4.0",
|
"fast-average-color": "^9.4.0",
|
||||||
"gsap": "^3.12.7",
|
"gsap": "^3.12.7",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
"isomorphic-dompurify": "^2.22.0",
|
"isomorphic-dompurify": "^2.22.0",
|
||||||
"jalali-ts": "^8.0.0",
|
"jalali-ts": "^8.0.0",
|
||||||
"nuxt": "^3.15.4",
|
"nuxt": "^3.15.4",
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
// import
|
// import
|
||||||
|
|
||||||
import { sanitize } from "isomorphic-dompurify";
|
|
||||||
import useGetArticle from "~/composables/api/blog/useGetArticle";
|
import useGetArticle from "~/composables/api/blog/useGetArticle";
|
||||||
|
|
||||||
// state
|
// state
|
||||||
@@ -24,16 +23,6 @@ if (response.isError) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// computed
|
|
||||||
|
|
||||||
const sanitizedArticleContent = computed(() => {
|
|
||||||
return sanitize(article.value!.content);
|
|
||||||
});
|
|
||||||
|
|
||||||
const sanitizedArticleSummery = computed(() => {
|
|
||||||
return sanitize(article.value!.summery);
|
|
||||||
});
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -48,7 +37,7 @@ const sanitizedArticleSummery = computed(() => {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
class="typo-p-lg text-slate-200 mb-6 text-justify w-[70%]"
|
class="typo-p-lg text-slate-200 mb-6 text-justify w-[70%]"
|
||||||
v-html="sanitizedArticleSummery"
|
v-html="article!.summery"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -103,7 +92,7 @@ const sanitizedArticleSummery = computed(() => {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
class="p-8 flex-1 text-zinc-800 flex flex-col gap-6 [&_p,ul]:text-zinc-500 [&_h1]:typo-h-4 [&_h2]:typo-h-5 [&_h3]:typo-h-6 [&_p]:typo-p-md [&_ul]:list-disc [&_ul]:typo-p-md [&_ul]:space-y-2"
|
class="p-8 flex-1 text-zinc-800 flex flex-col gap-6 [&_p,ul]:text-zinc-500 [&_h1]:typo-h-4 [&_h2]:typo-h-5 [&_h3]:typo-h-6 [&_p]:typo-p-md [&_ul]:list-disc [&_ul]:typo-p-md [&_ul]:space-y-2"
|
||||||
v-html="sanitizedArticleContent"
|
v-html="article!.content"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<aside class="mt-8 p-8 h-fit bg-slate-100 w-[400px] sticky top-4 rounded-3xl">
|
<aside class="mt-8 p-8 h-fit bg-slate-100 w-[400px] sticky top-4 rounded-3xl">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import ProductsGrid from "~/components/global/ProductsGrid.vue";
|
|||||||
// state
|
// state
|
||||||
|
|
||||||
const { data: homeData, suspense } = useHomeData();
|
const { data: homeData, suspense } = useHomeData();
|
||||||
|
const disableLoadingOverlay = useState("disableLoadingOverlay", () => false);
|
||||||
|
|
||||||
// ssr
|
// ssr
|
||||||
|
|
||||||
@@ -20,11 +21,17 @@ if (response.isError) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lifecycle
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<LoadingOverlay />
|
<LoadingOverlay v-if="!disableLoadingOverlay" />
|
||||||
<Hero class="mb-20 max-md:mt-[80px]" />
|
<Hero class="mb-20 max-md:mt-[80px]" />
|
||||||
<Preview />
|
<Preview />
|
||||||
<ProductsShowcase class="mb-40" />
|
<ProductsShowcase class="mb-40" />
|
||||||
|
|||||||
@@ -165,9 +165,10 @@ const handleSubmit = (withValidation: boolean) => {
|
|||||||
>
|
>
|
||||||
<div class="flex items-center justify-start gap-5 w-8/12">
|
<div class="flex items-center justify-start gap-5 w-8/12">
|
||||||
<div
|
<div
|
||||||
class="size-32 shrink-0 rounded-full border border-slate-200 flex-center relative"
|
class="relative shrink-0 rounded-full flex-center"
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
|
class="!size-32"
|
||||||
:src="account!.profile_photo"
|
:src="account!.profile_photo"
|
||||||
:alt="
|
:alt="
|
||||||
account?.first_name && account?.last_name
|
account?.first_name && account?.last_name
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ const handleLogin = async () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
navigateTo("/");
|
window.location.href = "/";
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
otpCode.value = [];
|
otpCode.value = [];
|
||||||
addToast({ message: "مشکلی پیش آمده" });
|
addToast({ message: "مشکلی پیش آمده" });
|
||||||
@@ -158,7 +158,7 @@ const resetForm = () => {
|
|||||||
mask: 'linear-gradient(to bottom, black 0%, rgba(0,0,0,0.3) 80%)',
|
mask: 'linear-gradient(to bottom, black 0%, rgba(0,0,0,0.3) 80%)',
|
||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center justify-center flex-col size-full">
|
<div class="flex items-center justify-center flex-col size-full translate-y-[-80px]">
|
||||||
<img
|
<img
|
||||||
class="aspect-square w-[300px] translate-y-[100px] animate-fade-in"
|
class="aspect-square w-[300px] translate-y-[100px] animate-fade-in"
|
||||||
src="/img/heymlz-seat.gif"
|
src="/img/heymlz-seat.gif"
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
// import
|
||||||
|
|
||||||
|
import hljs from "highlight.js";
|
||||||
|
import javascript from "highlight.js/lib/languages/javascript";
|
||||||
|
import "highlight.js/styles/atom-one-dark.css";
|
||||||
|
import LogDate from "~/components/server-logs/LogDate.vue";
|
||||||
|
import { useQuery } from "@tanstack/vue-query";
|
||||||
|
|
||||||
|
// meta
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware : "check-is-debug",
|
||||||
|
layout: "none"
|
||||||
|
});
|
||||||
|
|
||||||
|
// state
|
||||||
|
|
||||||
|
const { $axios: axios } = useNuxtApp();
|
||||||
|
|
||||||
|
const { data: serverLogs, isFetching, suspense } = useQuery({
|
||||||
|
queryKey: ["server-logs"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await axios.get("http://localhost:3000/api/server-logger");
|
||||||
|
return response.data.reverse();
|
||||||
|
},
|
||||||
|
refetchInterval: 5000,
|
||||||
|
staleTime: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
await suspense();
|
||||||
|
|
||||||
|
// computed
|
||||||
|
|
||||||
|
const logIcon = (status: number) => {
|
||||||
|
if (status >= 200 && status < 300) return "bi:check-circle-fill";
|
||||||
|
return "bi:x-circle-fill";
|
||||||
|
};
|
||||||
|
|
||||||
|
// lifecycle
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
hljs.registerLanguage("json", javascript);
|
||||||
|
hljs.highlightAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bg-neutral-900 w-full min-h-svh py-32">
|
||||||
|
<div class="fixed top-10 right-1/2 translate-x-1/2 flex-center" v-if="isFetching">
|
||||||
|
<Icon
|
||||||
|
name="svg-spinners:180-ring-with-bg"
|
||||||
|
class="size-12 mb-1 **:fill-neutral-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="w-full container flex flex-col gap-8">
|
||||||
|
<div
|
||||||
|
v-for="(log,index) in serverLogs"
|
||||||
|
:key="index"
|
||||||
|
class="border-2 p-5 rounded-xl log-item-animation"
|
||||||
|
:class="{
|
||||||
|
'bg-success-950/30 border-success-800' : log.status >= 200 && log.status < 300,
|
||||||
|
'bg-danger-950/30 border-danger-800' : log.status >= 400 && log.status < 600,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-4 mt-4">
|
||||||
|
<Icon
|
||||||
|
:name="logIcon(log.status)"
|
||||||
|
class="size-8 mb-1"
|
||||||
|
:class="{
|
||||||
|
'**:fill-success-500' : log.status >= 200 && log.status < 300,
|
||||||
|
'**:fill-danger-500' : log.status >= 400 && log.status < 600,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<h3 class="text-white font-medium text-3xl">
|
||||||
|
{{ log.url }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 py-8">
|
||||||
|
<div
|
||||||
|
class="px-3 pb-1 pt-1.5 rounded-lg uppercase font-bold text-white"
|
||||||
|
:class="{
|
||||||
|
'bg-success-500' : log.status >= 200 && log.status < 300,
|
||||||
|
'bg-danger-500' : log.status >= 400 && log.status < 600,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ log.method }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="px-3 pb-1 pt-1.5 rounded-lg font-bold text-white"
|
||||||
|
:class="{
|
||||||
|
'bg-success-500' : log.status >= 200 && log.status < 300,
|
||||||
|
'bg-danger-500' : log.status >= 400 && log.status < 600,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ log.status }}
|
||||||
|
</div>
|
||||||
|
<LogDate :date="log.date" />
|
||||||
|
</div>
|
||||||
|
<details class="text-white">
|
||||||
|
<summary class="cursor-pointer select-none">Details :</summary>
|
||||||
|
<div class="flex flex-col gap-2 mt-2 ml-4">
|
||||||
|
<details class="text-white">
|
||||||
|
<summary class="cursor-pointer select-none">Response :</summary>
|
||||||
|
<pre>
|
||||||
|
<code class="language-json">
|
||||||
|
{{ log.response }}
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
<details class="text-white">
|
||||||
|
<summary class="cursor-pointer select-none">Req Headers :</summary>
|
||||||
|
<pre class="whitespace-pre-line">
|
||||||
|
<code class="language-json">
|
||||||
|
{{ log.requestHeaders }}
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
<details class="text-white">
|
||||||
|
<summary class="cursor-pointer select-none">Res Headers :</summary>
|
||||||
|
<pre>
|
||||||
|
<code class="language-json">
|
||||||
|
{{ log.responseHeaders }}
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
<details v-if="log.payload" class="text-white">
|
||||||
|
<summary class="cursor-pointer select-none">Payload :</summary>
|
||||||
|
<pre>
|
||||||
|
<code class="language-json">
|
||||||
|
{{ log.payload }}
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
|
||||||
|
.log-item-animation {
|
||||||
|
animation-name: log-fade-in;
|
||||||
|
animation-duration: 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes log-fade-in {
|
||||||
|
from {
|
||||||
|
opacity : 0;
|
||||||
|
scale: 0.8;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity : 1;
|
||||||
|
scale: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -29,11 +29,10 @@ export default defineNuxtPlugin(() => {
|
|||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
async function(error) {
|
async function(error) {
|
||||||
await Logger.axiosErrorLog(error);
|
|
||||||
|
|
||||||
// if (error.status === 401) {
|
if (config.public.DEBUG === "true" && import.meta.server) {
|
||||||
// logout();
|
await Logger.axiosErrorLog(error);
|
||||||
// }
|
}
|
||||||
|
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 943 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,6 @@
|
|||||||
|
import fs from "fs/promises";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const oldLogs = await fs.readFile(".logs/log.json", "utf-8");
|
||||||
|
return JSON.parse(oldLogs) as Record<any, any>[];
|
||||||
|
});
|
||||||
+20
-80
@@ -1,91 +1,31 @@
|
|||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
|
|
||||||
type LogType = {
|
|
||||||
title: string;
|
|
||||||
status?: "success" | "error" | "info" | "warning";
|
|
||||||
message?: string,
|
|
||||||
details?: any
|
|
||||||
}
|
|
||||||
|
|
||||||
class Logger {
|
class Logger {
|
||||||
private static formatToMarkdown(log: LogType) {
|
|
||||||
const date = new Date();
|
|
||||||
let month = "" + (date.getMonth() + 1);
|
|
||||||
let day = "" + date.getDate();
|
|
||||||
let year = date.getFullYear();
|
|
||||||
let hour = date.getHours();
|
|
||||||
let minutes = date.getMinutes();
|
|
||||||
let seconds = date.getSeconds();
|
|
||||||
|
|
||||||
if (month.length < 2) {
|
|
||||||
month = "0" + month;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (day.length < 2) {
|
|
||||||
day = "0" + day;
|
|
||||||
}
|
|
||||||
|
|
||||||
let markdownContent = "";
|
|
||||||
|
|
||||||
let icon = "ℹ️";
|
|
||||||
|
|
||||||
switch (log.status) {
|
|
||||||
case "info":
|
|
||||||
icon = "ℹ️";
|
|
||||||
break;
|
|
||||||
case "error":
|
|
||||||
icon = "‼️";
|
|
||||||
break;
|
|
||||||
case "warning":
|
|
||||||
icon = "⚠️";
|
|
||||||
break;
|
|
||||||
case "success":
|
|
||||||
icon = "✅";
|
|
||||||
break;
|
|
||||||
default :
|
|
||||||
icon = "ℹ️";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
markdownContent += `## ${icon} ${log.title} \n`;
|
|
||||||
// markdownContent += `## ${[year, month, day].join("-")} ${hour}:${minutes}:${seconds} \n`;
|
|
||||||
markdownContent += `## ${hour} : ${minutes} : ${seconds} \n`;
|
|
||||||
|
|
||||||
if (log.message) {
|
|
||||||
markdownContent += `**Message:**\n ${log.message}\n\n`;
|
|
||||||
}
|
|
||||||
if (log.details) {
|
|
||||||
markdownContent += `**Details:**\n\n\`\`\`json\n${JSON.stringify(log.details, null, 2)}\n\`\`\`\n\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
markdownContent += "<br></br>\n\n";
|
|
||||||
markdownContent += "---\n";
|
|
||||||
|
|
||||||
return markdownContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async log(info: LogType) {
|
|
||||||
const formattedLog = this.formatToMarkdown(info);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fs.appendFile(".logs/log.md", formattedLog);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async axiosErrorLog(error: any) {
|
public static async axiosErrorLog(error: any) {
|
||||||
|
const errorJson = error.toJSON();
|
||||||
|
|
||||||
const logData : LogType = {
|
const nowDate = new Date();
|
||||||
title : error?.message,
|
|
||||||
message : `${error?.config?.method?.toUpperCase()} ${error?.config?.url}`,
|
|
||||||
details : error,
|
|
||||||
}
|
|
||||||
|
|
||||||
const formattedLog = this.formatToMarkdown(logData);
|
const logData: AxiosLogType = {
|
||||||
|
url: errorJson.config.url,
|
||||||
|
code: errorJson.code!,
|
||||||
|
status: errorJson.status!,
|
||||||
|
method: errorJson.config.method,
|
||||||
|
response: error?.response?.data,
|
||||||
|
requestHeaders: errorJson.config.headers,
|
||||||
|
responseHeaders: error.response.headers,
|
||||||
|
payload: errorJson.config.data ? JSON.parse(errorJson.config.data) : undefined,
|
||||||
|
params: errorJson.config.params ?? undefined,
|
||||||
|
date: nowDate.toString()
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.appendFile(".logs/log.md", formattedLog);
|
const oldLogs = await fs.readFile(".logs/log.json", "utf-8");
|
||||||
|
const oldLogsJson = JSON.parse(oldLogs) as Record<any, any>[];
|
||||||
|
|
||||||
|
oldLogsJson.push(logData);
|
||||||
|
|
||||||
|
await fs.writeFile(".logs/log.json", JSON.stringify(oldLogsJson));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+20
@@ -8,6 +8,26 @@ declare global {
|
|||||||
results: T[];
|
results: T[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LogType = {
|
||||||
|
title: string;
|
||||||
|
status?: "success" | "error" | "info" | "warning";
|
||||||
|
message?: string,
|
||||||
|
details?: any
|
||||||
|
}
|
||||||
|
|
||||||
|
type AxiosLogType = {
|
||||||
|
url: string,
|
||||||
|
method: string,
|
||||||
|
status: number,
|
||||||
|
code: string,
|
||||||
|
requestHeaders: Record<any, any>,
|
||||||
|
responseHeaders: Record<any, any>,
|
||||||
|
response?: Record<any, any>,
|
||||||
|
payload?: Record<any, any>,
|
||||||
|
params?: Record<any, any>,
|
||||||
|
date: string
|
||||||
|
}
|
||||||
|
|
||||||
type Chat = {
|
type Chat = {
|
||||||
id: number;
|
id: number;
|
||||||
sender: "ai" | "user";
|
sender: "ai" | "user";
|
||||||
|
|||||||
Reference in New Issue
Block a user