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:
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
+8 -1
View File
@@ -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]}'
+3
View File
@@ -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'),
]
+27 -9
View File
@@ -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'),
),
]
+1 -1
View File
@@ -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",
]
+3
View File
@@ -5,3 +5,6 @@ class OrderConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'order'
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 product.models import ProductModel, ProductVariant, ProductImageModel
from django.utils import timezone
from .execptions import DiscountNotAvailableError
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):
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
+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.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
View File
@@ -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': 'ادرس با موفقیت انتخاب شد'})
+3
View File
@@ -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):
+1 -1
View File
@@ -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"
+11 -13
View File
@@ -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"
>
+28 -9
View File
@@ -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]"
+2 -2
View File
@@ -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
+2 -1
View File
@@ -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>
+21 -19
View File
@@ -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>
+79 -88
View File
@@ -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;
+1 -8
View File
@@ -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>
+10 -1
View File
@@ -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;
+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: [
"~/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
}
}
});
+1
View File
@@ -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 -13
View File
@@ -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 -1
View File
@@ -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" />
+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="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
+2 -2
View File
@@ -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"
+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;
},
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

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