This commit is contained in:
Mamalizz
2025-03-22 16:27:34 +03:30
44 changed files with 721 additions and 317 deletions
+22 -5
View File
@@ -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
+8 -1
View File
@@ -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]}'
+3
View File
@@ -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'),
] ]
+27 -9
View File
@@ -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'),
),
]
+1 -1
View File
@@ -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",
] ]
+3
View File
@@ -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
-2
View File
@@ -1,2 +0,0 @@
class DiscountNotAvailableError(Exception):
pass
-1
View File
@@ -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
+16 -2
View File
@@ -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
+24
View File
@@ -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
+3 -2
View File
@@ -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
View File
@@ -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': 'ادرس با موفقیت انتخاب شد'})
+3
View File
@@ -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):
+1 -1
View File
@@ -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"
+11 -13
View File
@@ -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"
> >
+28 -9
View File
@@ -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]"
+2 -2
View File
@@ -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
+2 -1
View File
@@ -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>
+21 -19
View File
@@ -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>
+79 -88
View File
@@ -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;
+1 -8
View File
@@ -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>
+10 -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 { 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;
+9
View File
@@ -0,0 +1,9 @@
export default defineNuxtRouteMiddleware(() => {
const config = useRuntimeConfig();
if (config.public.DEBUG === "true") {
return;
} else {
return navigateTo("/");
}
});
+27 -26
View File
@@ -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
}, }
}
}); });
+1
View File
@@ -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 -13
View File
@@ -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 -1
View File
@@ -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" />
+2 -1
View File
@@ -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
+2 -2
View File
@@ -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"
+163
View File
@@ -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>
+3 -4
View File
@@ -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

+6
View File
@@ -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
View File
@@ -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);
} }
+20
View File
@@ -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";