merge
This commit is contained in:
@@ -8,10 +8,11 @@ on:
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Copy files to server
|
||||
uses: appleboy/scp-action@v0.1.6
|
||||
@@ -21,8 +22,19 @@ jobs:
|
||||
password: ${{ secrets.SSH_PASSWORD }}
|
||||
source: "."
|
||||
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
|
||||
with:
|
||||
host: ${{ secrets.SERVER_HOST }}
|
||||
@@ -30,6 +42,11 @@ jobs:
|
||||
password: ${{ secrets.SSH_PASSWORD }}
|
||||
script: |
|
||||
cd /root/hshop/
|
||||
docker compose down
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
|
||||
docker compose down --remove-orphans --timeout 60
|
||||
|
||||
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}'
|
||||
class Meta:
|
||||
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 djoser.urls.jwt import views as djoser_jwt_views
|
||||
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path('profile', views.ProfileView.as_view()),
|
||||
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/<int:pk>', views.GetIDUserAddressView.as_view(), name='get-ID-address'),
|
||||
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('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
|
||||
import ghasedak_sms
|
||||
from django.views import View
|
||||
# this works only need to be used
|
||||
# class APIView(APIView):
|
||||
# def __init__(self, *args, **kwargs):
|
||||
# super().__init__(*args, **kwargs)
|
||||
# if AllowAny in self.permission_classes or not self.permission_classes:
|
||||
# self.authentication_classes = []
|
||||
from rest_framework import serializers
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
|
||||
|
||||
class SendOTPView(APIView):
|
||||
permission_classes = [AllowAny]
|
||||
@extend_schema(
|
||||
@@ -143,7 +142,9 @@ class CreateAddressView(generics.CreateAPIView):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
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):
|
||||
queryset = UserAddressModel.objects.all()
|
||||
@@ -190,6 +191,25 @@ class SubscribeView(APIView):
|
||||
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):
|
||||
def get(self, request, pk):
|
||||
attack = get_object_or_404(SecurityBreachAttemptModel, pk=pk)
|
||||
@@ -198,8 +218,6 @@ class ChangeViewAttack(View):
|
||||
return redirect('admin:account_securitybreachattemptmodel_changelist')
|
||||
|
||||
|
||||
from rest_framework import serializers
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
class LogoutSerializer(serializers.Serializer):
|
||||
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
|
||||
# ==============================================================================
|
||||
STATICFILES_DIRS = [
|
||||
os.path.join(BASE_DIR, "custom_static"),
|
||||
# os.path.join(BASE_DIR, "custom_static"),
|
||||
BASE_DIR / "core" / "static",
|
||||
]
|
||||
|
||||
|
||||
@@ -5,3 +5,6 @@ class OrderConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'order'
|
||||
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 product.models import ProductModel, ProductVariant, ProductImageModel
|
||||
from django.utils import timezone
|
||||
from .execptions import DiscountNotAvailableError
|
||||
from django_jalali.db import models as jmodels
|
||||
|
||||
|
||||
|
||||
@@ -20,7 +20,21 @@ class GetOrderPermission(BasePermission):
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if obj.user != request.user:
|
||||
return False
|
||||
if obj.status != 'CART':
|
||||
if obj.status == 'CART':
|
||||
self.message = "سفارش در وضعیت سبد خرید است"
|
||||
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.contrib import admin
|
||||
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
|
||||
|
||||
urlpatterns = [
|
||||
path('all', OrderlistView.as_view(), name='order-list'),
|
||||
path('cart', CartView.as_view()),
|
||||
path('cart/set-address', SetAddressForCartView.as_view()),
|
||||
path('cart/discount', ApplyDiscountView.as_view()),
|
||||
path('cart/all', CartItemClear.as_view()),
|
||||
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('<int:pk>', OrderGetView.as_view(), name='order-get'),
|
||||
]
|
||||
|
||||
+45
-13
@@ -1,5 +1,4 @@
|
||||
from django.shortcuts import render
|
||||
from .execptions import DiscountNotAvailableError
|
||||
from rest_framework.views import APIView, Response
|
||||
from django.shortcuts import get_object_or_404
|
||||
from product.models import ProductVariant
|
||||
@@ -8,25 +7,20 @@ from .serializers import *
|
||||
# from cart.models import
|
||||
from rest_framework import status
|
||||
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.exceptions import AZBankGatewaysException
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes
|
||||
from utils.pagination import StructurePagination
|
||||
from order.models import OrderModel
|
||||
try:
|
||||
pass
|
||||
except DiscountNotAvailableError:
|
||||
pass
|
||||
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})
|
||||
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):
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = BankTypeSerializer
|
||||
@extend_schema(
|
||||
description="choices=['BMI', 'SEP', 'ZARINPAL', 'IDPAY', 'ZIBAL', 'BAHAMTA', 'MELLAT', 'PAYV1']"
|
||||
)
|
||||
def post(self, request):
|
||||
print(request.data.get('gateway_type'))
|
||||
cart_order = get_object_or_404(OrderModel, user=request.user, status='CART')
|
||||
amount = 5000
|
||||
user_mobile_number = request.user.phone
|
||||
@@ -245,4 +252,29 @@ def callback_view(request):
|
||||
|
||||
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)
|
||||
details = ProductDetailSerializer(many=True, read_only=True)
|
||||
cart_quantity = serializers.SerializerMethodField()
|
||||
price = serializers.SerializerMethodField()
|
||||
class Meta:
|
||||
model = ProductVariant
|
||||
exclude = ('min_price', 'sell', 'currency', 'product', 'input_price')
|
||||
@@ -72,6 +73,8 @@ class ProductVariantSerialzier(serializers.ModelSerializer):
|
||||
return item['quantity']
|
||||
return 0
|
||||
|
||||
def get_pirce(self, obj):
|
||||
return f'{obj.price:,.0f} تومان'
|
||||
|
||||
|
||||
class SubCategorySerializer(serializers.ModelSerializer):
|
||||
|
||||
@@ -23,7 +23,7 @@ const { isLoading } = useImage({ src: src.value });
|
||||
|
||||
<template>
|
||||
<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
|
||||
v-if="isLoading"
|
||||
|
||||
@@ -32,7 +32,7 @@ const isHomePage = computed(() => route.path === "/");
|
||||
<div
|
||||
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" />
|
||||
</button>
|
||||
<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="حساب کاربری">
|
||||
<NuxtLink
|
||||
: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
|
||||
class="!size-[1.6rem]"
|
||||
class="!size-7"
|
||||
:src="account.profile_photo"
|
||||
:alt="
|
||||
account.first_name && account.last_name
|
||||
@@ -66,7 +66,7 @@ const isHomePage = computed(() => route.path === "/");
|
||||
</NuxtLink>
|
||||
</Tooltip>
|
||||
<Tooltip title="محصولات">
|
||||
<NuxtLink to="/products" class="flex-center">
|
||||
<NuxtLink to="/products" class="flex-center header-navbar-item">
|
||||
<Icon
|
||||
name="ci:search"
|
||||
class="**:stroke-black size-4.5 lg:size-[21px]"
|
||||
@@ -78,22 +78,20 @@ const isHomePage = computed(() => route.path === "/");
|
||||
<button class="relative">
|
||||
<Icon
|
||||
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"
|
||||
class="size-4 shrink-0 absolute -bottom-2 -right-2 flex-center rounded-sm text-white bg-red-600 text-xs animate-pulse"
|
||||
>
|
||||
{{ cart?.items.length }}
|
||||
</span>
|
||||
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"
|
||||
/>
|
||||
</button>
|
||||
</NuxtLink>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<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
|
||||
v-for="(link, index) in NAV_LINKS"
|
||||
@@ -105,10 +103,10 @@ const isHomePage = computed(() => route.path === "/");
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="header-navbar-item">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 lg:h-6"
|
||||
class="h-5 lg:h-6 "
|
||||
fill="none"
|
||||
viewBox="0 0 220 40"
|
||||
>
|
||||
|
||||
@@ -3,29 +3,33 @@
|
||||
// state
|
||||
|
||||
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) => {
|
||||
if (!value) {
|
||||
const timeline = gsap.timeline();
|
||||
|
||||
const imageElement = document.querySelector("#loading-overlay-image") as HTMLImageElement;
|
||||
|
||||
imageElement.src = "/img/heymlz-loading-2.gif";
|
||||
|
||||
timeline
|
||||
.to("#loading-overlay", {
|
||||
scale: 1
|
||||
})
|
||||
.to("#loading-overlay", {
|
||||
scale: 0.8,
|
||||
opacity: 0,
|
||||
delay: 2.5
|
||||
})
|
||||
.to("#loading-overlay", {
|
||||
opacity: 0,
|
||||
y: "20%",
|
||||
delay: 2,
|
||||
onComplete: () => {
|
||||
shouldRenderLoadingOverlay.value = false;
|
||||
isWindowScrollLocked.value = false;
|
||||
disableLoadingOverlay.value = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -33,6 +37,16 @@ watch(() => showLoadingOverlay.value, (value) => {
|
||||
once: true
|
||||
});
|
||||
|
||||
|
||||
// lifecycle
|
||||
|
||||
onMounted(() => {
|
||||
isWindowScrollLocked.value = true;
|
||||
|
||||
const newImage = new Image();
|
||||
newImage.src = "/img/heymlz-loading-2.gif";
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -41,7 +55,12 @@ watch(() => showLoadingOverlay.value, (value) => {
|
||||
id="loading-overlay"
|
||||
class="fixed inset-0 size-full z-9999 flex-center bg-black"
|
||||
>
|
||||
<img id="loading-overlay-image" src="/video/loading-2.gif" class="opacity-0 scale-70 absolute z-20" alt="" />
|
||||
<img
|
||||
id="loading-overlay-image"
|
||||
src="/img/heymlz-loading-1.gif"
|
||||
class="opacity-0 scale-70 absolute z-20"
|
||||
alt=""
|
||||
/>
|
||||
<div
|
||||
id="loading-overlay-gradient"
|
||||
class="opacity-0 scale-x-0 w-[1000px] h-[70px] bg-linear-to-r from-blue-500 via-violet-500 to-purple-500 blur-[150px] rounded-[100px]"
|
||||
|
||||
@@ -49,10 +49,10 @@ const closeSideDrawer = () => {
|
||||
<Tooltip v-if="!!account && !!token" title="حساب کاربری">
|
||||
<NuxtLink
|
||||
: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
|
||||
class="!size-[1.6rem]"
|
||||
class="!size-7"
|
||||
:src="account.profile_photo"
|
||||
:alt="
|
||||
account.first_name && account.last_name
|
||||
|
||||
@@ -51,9 +51,10 @@ const { data: account } = useGetAccount();
|
||||
<template #trigger>
|
||||
<button
|
||||
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
|
||||
class="!size-7"
|
||||
:src="account.profile_photo"
|
||||
:alt="
|
||||
account.first_name && account.last_name
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
type Props = {
|
||||
modelValue: number;
|
||||
max: number;
|
||||
disable: boolean;
|
||||
}
|
||||
|
||||
// props
|
||||
@@ -48,6 +49,7 @@ const onInput = (e: any) => {
|
||||
|
||||
<template>
|
||||
<NumberFieldRoot
|
||||
:disabled="disable"
|
||||
class="rounded-full border-slate-200 border-[1.5px] flex items-center bg-white gap-4 p-4"
|
||||
v-model="currentQuantity"
|
||||
:min="1"
|
||||
@@ -58,7 +60,7 @@ const onInput = (e: any) => {
|
||||
</NumberFieldIncrement>
|
||||
<NumberFieldInput
|
||||
@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">
|
||||
<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
|
||||
|
||||
const onSwiper = (swiper: SwiperClass) => {
|
||||
showLoadingOverlay.value = false;
|
||||
setTimeout(() => {
|
||||
showLoadingOverlay.value = false;
|
||||
}, 1000);
|
||||
swiper_instance.value = swiper;
|
||||
};
|
||||
|
||||
@@ -70,11 +72,9 @@ const initializeGsapAnimation = () => {
|
||||
}, {
|
||||
value: 1.2
|
||||
}, "=")
|
||||
.fromTo("#header-navbar", {
|
||||
background: "transparent",
|
||||
.fromTo(".header-navbar-item", {
|
||||
filter: "invert(100%)"
|
||||
}, {
|
||||
background: "transparent",
|
||||
filter: "invert(0%)"
|
||||
}, "=")
|
||||
.fromTo("#header-navbar", {
|
||||
@@ -96,7 +96,9 @@ const initializeGsapAnimation = () => {
|
||||
|
||||
const resetTimelineForMobile = () => {
|
||||
gsap.to("#header-navbar", {
|
||||
background: "white",
|
||||
background: "white"
|
||||
});
|
||||
gsap.to(".header-navbar-item", {
|
||||
filter: "invert(0%)"
|
||||
});
|
||||
gsap.set(".header-slider-item", {
|
||||
@@ -113,6 +115,7 @@ onMounted(() => {
|
||||
initializeGsapAnimation();
|
||||
|
||||
scrollTrigger = ScrollTrigger.create({
|
||||
anticipatePin: 1,
|
||||
trigger: "#header-slider-container",
|
||||
animation: gsapTimeline,
|
||||
scrub: 1,
|
||||
@@ -124,27 +127,23 @@ onMounted(() => {
|
||||
|
||||
const calculateOnResize = () => {
|
||||
if (window.innerWidth > 768) {
|
||||
gsap.to("#header-navbar", {
|
||||
background: "transparent",
|
||||
filter: "invert(100%)"
|
||||
});
|
||||
scrollTrigger.enable();
|
||||
} else {
|
||||
resetTimelineForMobile();
|
||||
scrollTrigger.disable();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
calculateOnResize()
|
||||
calculateOnResize();
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
calculateOnResize()
|
||||
calculateOnResize();
|
||||
}, 200);
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
calculateOnResize()
|
||||
calculateOnResize();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -213,7 +212,8 @@ onUnmounted(() => {
|
||||
<div class="flex items-center gap-4 lg:gap-8">
|
||||
<div
|
||||
class="hover:scale-110 size-[36px] md:size-[42px] lg:size-[50px] relative flex items-center justify-center">
|
||||
<div class="size-full scale-75 bg-white absolute rounded-full animate-ping" />
|
||||
<div
|
||||
class="size-full scale-75 bg-white absolute rounded-full animate-ping" />
|
||||
<button
|
||||
@click="isMuted = !isMuted"
|
||||
class="transition-all cursor-pointer flex-center bg-white z-10 size-full rounded-full"
|
||||
@@ -232,11 +232,13 @@ onUnmounted(() => {
|
||||
<span class="truncate typo-p-xs md:typo-p-sm lg:typo-p-lg text-white">
|
||||
{{ slide.description }}
|
||||
</span>
|
||||
<Button
|
||||
class="max-sm:hidden max-lg:typo-label-xs invert rounded-full hover:bg-transparent"
|
||||
>
|
||||
مشاهده
|
||||
</Button>
|
||||
<NuxtLink :to="slide.link">
|
||||
<Button
|
||||
class="max-sm:hidden max-lg:typo-label-xs invert rounded-full hover:bg-transparent"
|
||||
>
|
||||
مشاهده
|
||||
</Button>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import useHomeData from "~/composables/api/home/useHomeData";
|
||||
|
||||
// state
|
||||
|
||||
const { data : homeData } = useHomeData();
|
||||
const { data: homeData } = useHomeData();
|
||||
|
||||
const { $gsap: gsap, $ScrollTrigger: ScrollTrigger } = useNuxtApp();
|
||||
|
||||
@@ -17,71 +17,60 @@ let scrollTrigger: ScrollTrigger;
|
||||
|
||||
onMounted(() => {
|
||||
gsapTimeline = gsap.timeline();
|
||||
|
||||
gsapTimeline
|
||||
.fromTo("#header-navbar", {
|
||||
background: "white",
|
||||
filter: "invert(0%)"
|
||||
}, {
|
||||
background: "transparent",
|
||||
filter: "invert(100%)"
|
||||
});
|
||||
|
||||
const showcaseElements = gsap.utils.toArray<HTMLElement>(".showcase-slide");
|
||||
|
||||
showcaseElements.forEach((element, index) => {
|
||||
|
||||
gsapTimeline.fromTo(element, index === 0 ? {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
// rotateX: -25,
|
||||
y: 0,
|
||||
ease: "none"
|
||||
} : {
|
||||
opacity: 0,
|
||||
scale: 0.97,
|
||||
// rotateX: -25,
|
||||
y: 20,
|
||||
ease: "none"
|
||||
}, {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
// rotateX: 0,
|
||||
y: 0,
|
||||
ease: "none"
|
||||
}, index === 0 ? "-=0%" : undefined);
|
||||
|
||||
if (index < showcaseElements.length - 1) {
|
||||
gsapTimeline.to(element, {
|
||||
opacity: 0,
|
||||
scale: 1.03,
|
||||
// rotateX: 25,
|
||||
y: -20,
|
||||
ease: "none"
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
gsapTimeline.to("#header-navbar", {
|
||||
background: "white",
|
||||
filter: "invert(0%)"
|
||||
});
|
||||
|
||||
scrollTrigger = ScrollTrigger.create({
|
||||
trigger: "#products-showcase-container",
|
||||
animation: gsapTimeline,
|
||||
scrub: 1,
|
||||
pin: true,
|
||||
start: "top top",
|
||||
// markers: true,
|
||||
end: "bottom top"
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
scrollTrigger.refresh()
|
||||
}, 1000);
|
||||
showcaseElements.forEach((element, index) => {
|
||||
|
||||
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(() => {
|
||||
@@ -92,33 +81,35 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
<section
|
||||
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
|
||||
v-for="slide in homeData!.show_case_slider"
|
||||
:key="slide.id"
|
||||
:to="slide.link"
|
||||
class="showcase-slide origin-bottom absolute size-full bg-transparent flex items-center justify-center"
|
||||
>
|
||||
<div class="w-full h-[125svh] bg-black">
|
||||
<NuxtLink
|
||||
v-for="slide in homeData!.show_case_slider"
|
||||
:key="slide.id"
|
||||
:to="slide.link"
|
||||
class="showcase-slide origin-bottom absolute size-full bg-transparent flex items-center justify-center"
|
||||
>
|
||||
|
||||
<img
|
||||
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"
|
||||
:style="{
|
||||
mask: 'linear-gradient(to bottom, black 0%, rgba(0,0,0,0) 100%)',
|
||||
}"
|
||||
alt=""
|
||||
/>
|
||||
<div class="flex flex-col items-center justify-center gap-4 text-center absolute z-20 mt-20">
|
||||
<span class="text-white typo-h-6 sm:typo-h-5 lg:typo-h-4 xl:typo-h-3">
|
||||
{{ slide.title }}
|
||||
</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">
|
||||
{{ slide.description }}
|
||||
</p>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<img
|
||||
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"
|
||||
:style="{
|
||||
mask: 'linear-gradient(to bottom, black 0%, rgba(0,0,0,0) 100%)',
|
||||
}"
|
||||
alt=""
|
||||
/>
|
||||
<div class="flex flex-col items-center justify-center gap-4 text-center absolute z-20 mt-20">
|
||||
<span class="text-white typo-h-6 sm:typo-h-5 lg:typo-h-4 xl:typo-h-3">
|
||||
{{ slide.title }}
|
||||
</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">
|
||||
{{ slide.description }}
|
||||
</p>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
// provide / inject
|
||||
|
||||
import type { ProductVariantProvideType } from "~/pages/product/types";
|
||||
import type { ProductVariantProvideType } from "~/pages/product";
|
||||
|
||||
const { selectedVariant } = inject("productVariant") as ProductVariantProvideType;
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
// import
|
||||
|
||||
import useGetProduct from "~/composables/api/product/useGetProduct";
|
||||
import { sanitize } from "isomorphic-dompurify";
|
||||
import type { ProductVariantProvideType } from "~/pages/product/[id].vue";
|
||||
import useAddCartItem from "~/composables/api/orders/useAddCartItem";
|
||||
import { useAuth } from "~/composables/api/auth/useAuth";
|
||||
@@ -37,12 +36,6 @@ const addItemToCart = async () => {
|
||||
await refetchProduct();
|
||||
};
|
||||
|
||||
// computed
|
||||
|
||||
const sanitizedProductDescription = computed(() => {
|
||||
return sanitize(product.value!.description);
|
||||
});
|
||||
|
||||
// watch
|
||||
|
||||
watch(() => selectedVariantId.value, (newId) => {
|
||||
@@ -147,7 +140,7 @@ watch(() => selectedVariant.value!, (newValue) => {
|
||||
|
||||
<div
|
||||
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">
|
||||
|
||||
@@ -71,7 +71,7 @@ await suspense();
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<Avatar
|
||||
class="!size-[3rem]"
|
||||
class="!size-12"
|
||||
:src="account!.profile_photo"
|
||||
:alt="
|
||||
account?.first_name && account?.last_name
|
||||
|
||||
@@ -134,15 +134,11 @@ onFileDialogChange((files: any) => {
|
||||
</div>
|
||||
|
||||
<div class="w-full flex-col-center gap-5">
|
||||
<div
|
||||
class="size-32 border border-slate-200 rounded-full"
|
||||
>
|
||||
<Avatar
|
||||
:src="currentProfile"
|
||||
:alt="''"
|
||||
class="size-full"
|
||||
/>
|
||||
</div>
|
||||
<Avatar
|
||||
:src="currentProfile"
|
||||
alt=""
|
||||
class="!size-32"
|
||||
/>
|
||||
<Button
|
||||
class="rounded-full w-[8rem]"
|
||||
@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 { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
|
||||
import { sanitize } from "isomorphic-dompurify";
|
||||
|
||||
// types
|
||||
|
||||
@@ -22,7 +23,15 @@ const useGetArticle = (id: number | string | undefined) => {
|
||||
|
||||
return useQuery({
|
||||
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 { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
|
||||
import { sanitize } from "isomorphic-dompurify";
|
||||
|
||||
// types
|
||||
|
||||
@@ -26,6 +27,8 @@ const useGetProduct = (id: string | number | undefined) => {
|
||||
select: (product) => {
|
||||
const copyOfProduct = { ...product };
|
||||
|
||||
copyOfProduct.description = sanitize(copyOfProduct.description);
|
||||
|
||||
copyOfProduct.variants = copyOfProduct.variants.sort((a, b) => b.in_stock - a.in_stock);
|
||||
|
||||
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: [
|
||||
"~/assets/css/tailwind.css",
|
||||
"swiper/css",
|
||||
"animate.css/animate.min.css",
|
||||
"animate.css/animate.min.css"
|
||||
],
|
||||
|
||||
routeRules: {
|
||||
"/products": { prerender: false, ssr: false },
|
||||
"/products": { prerender: false, ssr: false }
|
||||
},
|
||||
|
||||
app: {
|
||||
head: {
|
||||
title: "فروشگاه هی ملز",
|
||||
title: "فروشگاه هی ملز"
|
||||
},
|
||||
pageTransition: {
|
||||
enterActiveClass:
|
||||
"animate__animated animate__fadeIn animate__faster",
|
||||
leaveActiveClass:
|
||||
"animate__animated animate__fadeOut animate__faster",
|
||||
mode: "out-in",
|
||||
mode: "out-in"
|
||||
},
|
||||
layoutTransition: {
|
||||
enterActiveClass:
|
||||
"animate__animated animate__fadeIn animate__faster",
|
||||
leaveActiveClass:
|
||||
"animate__animated animate__fadeOut animate__faster",
|
||||
mode: "out-in",
|
||||
},
|
||||
mode: "out-in"
|
||||
}
|
||||
},
|
||||
|
||||
postcss: {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
autoprefixer: {}
|
||||
}
|
||||
},
|
||||
|
||||
components: [
|
||||
{
|
||||
path: "~/components",
|
||||
pathPrefix: false,
|
||||
},
|
||||
pathPrefix: false
|
||||
}
|
||||
],
|
||||
|
||||
icon: {
|
||||
@@ -52,9 +52,9 @@ export default defineNuxtConfig({
|
||||
customCollections: [
|
||||
{
|
||||
prefix: "ci",
|
||||
dir: "./public/icons",
|
||||
},
|
||||
],
|
||||
dir: "./public/icons"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
modules: [
|
||||
@@ -65,15 +65,15 @@ export default defineNuxtConfig({
|
||||
"DM Sans": "100..900",
|
||||
Inter: "100..900",
|
||||
download: true,
|
||||
inject: false,
|
||||
},
|
||||
},
|
||||
inject: false
|
||||
}
|
||||
}
|
||||
],
|
||||
"@nuxt/icon",
|
||||
"reka-ui/nuxt",
|
||||
"@vueuse/nuxt",
|
||||
"@formkit/auto-animate/nuxt",
|
||||
"@vite-pwa/nuxt",
|
||||
"@vite-pwa/nuxt"
|
||||
],
|
||||
|
||||
pwa: {
|
||||
@@ -88,26 +88,27 @@ export default defineNuxtConfig({
|
||||
{
|
||||
src: "/logo/logo-192x192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
type: "image/png"
|
||||
},
|
||||
{
|
||||
src: "/logo/logo-512x512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
},
|
||||
],
|
||||
type: "image/png"
|
||||
}
|
||||
]
|
||||
},
|
||||
workbox: {
|
||||
navigateFallback: "/",
|
||||
clientsClaim: true,
|
||||
skipWaiting: true,
|
||||
skipWaiting: true
|
||||
},
|
||||
devOptions: { enabled: true, type: "module" },
|
||||
devOptions: { enabled: true, type: "module" }
|
||||
},
|
||||
|
||||
runtimeConfig: {
|
||||
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",
|
||||
"fast-average-color": "^9.4.0",
|
||||
"gsap": "^3.12.7",
|
||||
"highlight.js": "^11.11.1",
|
||||
"isomorphic-dompurify": "^2.22.0",
|
||||
"jalali-ts": "^8.0.0",
|
||||
"nuxt": "^3.15.4",
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
// import
|
||||
|
||||
import { sanitize } from "isomorphic-dompurify";
|
||||
import useGetArticle from "~/composables/api/blog/useGetArticle";
|
||||
|
||||
// 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>
|
||||
|
||||
<template>
|
||||
@@ -48,7 +37,7 @@ const sanitizedArticleSummery = computed(() => {
|
||||
|
||||
<div
|
||||
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">
|
||||
@@ -103,7 +92,7 @@ const sanitizedArticleSummery = computed(() => {
|
||||
|
||||
<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"
|
||||
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">
|
||||
|
||||
@@ -8,6 +8,7 @@ import ProductsGrid from "~/components/global/ProductsGrid.vue";
|
||||
// state
|
||||
|
||||
const { data: homeData, suspense } = useHomeData();
|
||||
const disableLoadingOverlay = useState("disableLoadingOverlay", () => false);
|
||||
|
||||
// ssr
|
||||
|
||||
@@ -20,11 +21,17 @@ if (response.isError) {
|
||||
});
|
||||
}
|
||||
|
||||
// lifecycle
|
||||
|
||||
onMounted(() => {
|
||||
window.scrollTo(0, 0);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<LoadingOverlay />
|
||||
<LoadingOverlay v-if="!disableLoadingOverlay" />
|
||||
<Hero class="mb-20 max-md:mt-[80px]" />
|
||||
<Preview />
|
||||
<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="size-32 shrink-0 rounded-full border border-slate-200 flex-center relative"
|
||||
class="relative shrink-0 rounded-full flex-center"
|
||||
>
|
||||
<Avatar
|
||||
class="!size-32"
|
||||
:src="account!.profile_photo"
|
||||
:alt="
|
||||
account?.first_name && account?.last_name
|
||||
|
||||
@@ -123,7 +123,7 @@ const handleLogin = async () => {
|
||||
},
|
||||
});
|
||||
|
||||
navigateTo("/");
|
||||
window.location.href = "/";
|
||||
} catch (e) {
|
||||
otpCode.value = [];
|
||||
addToast({ message: "مشکلی پیش آمده" });
|
||||
@@ -158,7 +158,7 @@ const resetForm = () => {
|
||||
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
|
||||
class="aspect-square w-[300px] translate-y-[100px] animate-fade-in"
|
||||
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;
|
||||
},
|
||||
async function(error) {
|
||||
await Logger.axiosErrorLog(error);
|
||||
|
||||
// if (error.status === 401) {
|
||||
// logout();
|
||||
// }
|
||||
if (config.public.DEBUG === "true" && import.meta.server) {
|
||||
await Logger.axiosErrorLog(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";
|
||||
|
||||
type LogType = {
|
||||
title: string;
|
||||
status?: "success" | "error" | "info" | "warning";
|
||||
message?: string,
|
||||
details?: any
|
||||
}
|
||||
|
||||
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) {
|
||||
const errorJson = error.toJSON();
|
||||
|
||||
const logData : LogType = {
|
||||
title : error?.message,
|
||||
message : `${error?.config?.method?.toUpperCase()} ${error?.config?.url}`,
|
||||
details : error,
|
||||
}
|
||||
const nowDate = new Date();
|
||||
|
||||
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 {
|
||||
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) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
Vendored
+20
@@ -8,6 +8,26 @@ declare global {
|
||||
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 = {
|
||||
id: number;
|
||||
sender: "ai" | "user";
|
||||
|
||||
Reference in New Issue
Block a user