This commit is contained in:
Mamalizz
2025-01-07 18:14:43 +03:30
77 changed files with 2711 additions and 456 deletions
@@ -0,0 +1,23 @@
# Generated by Django 5.1.2 on 2024-12-31 17:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0007_remove_user_otp_user_otp_hash_alter_user_otp_expiry'),
]
operations = [
migrations.AlterField(
model_name='user',
name='otp_expiry',
field=models.DateTimeField(blank=True, null=True, verbose_name='تاریخ تمام شدن کد یک بار مصرف'),
),
migrations.AlterField(
model_name='user',
name='otp_hash',
field=models.CharField(blank=True, max_length=64, null=True, verbose_name='کد یک بار مصرف'),
),
]
+1 -2
View File
@@ -85,9 +85,8 @@ class User(AbstractBaseUser, PermissionsMixin):
tokens = OutstandingToken.objects.filter(user=self)
for token in tokens:
BlacklistedToken.objects.get_or_create(token=token)
print('done')
except Exception as e:
print(f"ridi dadash")
print(f"block list error: {e}")
def __str__(self):
+4 -3
View File
@@ -13,6 +13,7 @@ import ghasedak_sms
class SendOTPView(APIView):
permission_classes = [AllowAny]
@extend_schema(
tags=["Authentication"],
request={
"application/json": {
"type": "object",
@@ -68,9 +69,9 @@ class SendOTPView(APIView):
class CustomTokenObtainPairView(TokenObtainPairView):
serializer_class = CustomTokenObtainPairSerializer
# @extend_schema(
# tags=["Authentication"]
# )
@extend_schema(
tags=["Authentication"]
)
def post(self, request, *args, **kwargs):
phone = request.data.get("phone")
otp = request.data.get("otp")
+8
View File
@@ -0,0 +1,8 @@
from django.contrib import admin
from .models import *
from unfold.admin import ModelAdmin
@admin.register(ProductChatModel)
class ProductChatAdmin(ModelAdmin):
pass
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ChatConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'chat'
+30
View File
@@ -0,0 +1,30 @@
# Generated by Django 5.1.2 on 2025-01-01 17:58
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('product', '0005_categorymodel'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ProductChatModel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('thread', models.CharField(blank=True, max_length=400, null=True)),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='product.productmodel')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'product')},
},
),
]
+60
View File
@@ -0,0 +1,60 @@
from django.db import models
from account.models import User
from product.models import ProductModel
from django.conf import settings
import openai
from time import sleep
from product.serializers import ProductChatSerializer
ASSISTANT_ID = 'asst_1wOnCKncEHkOfp0FjOIz4Xkp'
class ProductChatModel(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
product = models.ForeignKey(ProductModel, on_delete=models.CASCADE)
thread = models.CharField(max_length=400, blank=True, null=True)
class Meta:
unique_together = ['user', 'product']
def __str__(self):
return f'{self.user} - {self.product}'
def save(self, *args, **kwargs):
if not self.thread:
client = openai.OpenAI(api_key=settings.OPENAI_API_KEY)
product_json = ProductChatSerializer(instance=self.product).data
print(product_json)
try:
thread = client.beta.threads.create(
messages=[
{
"role": "user",
#TODO update first message
"content": f"this is the start of the chat greet the user this chat is about the product with given detail: {product_json}",
}
]
)
run = client.beta.threads.runs.create(
thread_id=thread.id,
assistant_id=ASSISTANT_ID
)
while run.status != "completed":
run = client.beta.threads.runs.retrieve(
thread_id=thread.id,
run_id=run.id
)
sleep(1)
self.thread = thread.id
except Exception as e:
print(f'error in chat class: {e}')
raise ValueError(f"Error creating OpenAI thread: {e}")
super().save(*args, **kwargs)
View File
+6
View File
@@ -0,0 +1,6 @@
from django.urls import path
from . import views
urlpatterns = [
path('product/<int:pk>', views.ProductChatView.as_view(), name='product-chat-view'),
]
+162
View File
@@ -0,0 +1,162 @@
from django.core.paginator import Paginator
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from rest_framework import status
from rest_framework.response import Response
from django.shortcuts import get_object_or_404
from django.db.models import Q
from time import sleep
import json
from drf_spectacular.utils import (
extend_schema,
extend_schema_view,
OpenApiParameter,
OpenApiExample,
)
from rest_framework import serializers
from rest_framework.pagination import LimitOffsetPagination
from .models import ProductModel, ProductChatModel
from utils.pagination import StructurePagination
import openai
from django.conf import settings
ASSISTANT_ID = 'asst_1wOnCKncEHkOfp0FjOIz4Xkp'
# Serializer for the `new_message` field in POST requests
class NewMessageSerializer(serializers.Serializer):
new_message = serializers.CharField(
required=True,
help_text="The message content to send to the chat."
)
# Custom Pagination Class
class StructurePagination(LimitOffsetPagination):
default_limit = 10
max_limit = 100
# Documentation for the ProductChatView
@extend_schema_view(
get=extend_schema(
summary="Retrieve messages for a product chat",
parameters=[
OpenApiParameter(
name="limit",
description="Number of results to return per page.",
required=False,
type=int,
default=10,
),
OpenApiParameter(
name="offset",
description="The starting position of the results.",
required=False,
type=int,
default=0,
),
],
responses={
200: OpenApiExample(
"Chat messages retrieved",
value={
"messages": [
{"sender": "user", "content": "Hello!"},
{"sender": "assistant", "content": "How can I help you?"}
]
}
),
},
),
post=extend_schema(
summary="Send a new message in the product chat",
request=NewMessageSerializer,
responses={
200: OpenApiExample(
"Message sent successfully",
value={
"messages": [
{"sender": "user", "content": "Hello!"},
{"sender": "assistant", "content": "How can I help you?"}
]
}
),
400: OpenApiExample(
"Error example",
value={"detail": "پیام جدید نمی‌تواند خالی باشد"},
),
},
),
)
class ProductChatView(APIView):
permission_classes = [IsAuthenticated]
pagination_class = StructurePagination
def get(self, request, pk):
"""
Retrieve all messages for a product chat.
"""
user = request.user
product = get_object_or_404(ProductModel, id=pk)
chat, created = ProductChatModel.objects.get_or_create(user=user, product=product)
client = openai.OpenAI(api_key=settings.OPENAI_API_KEY)
message_response = client.beta.threads.messages.list(thread_id=chat.thread)
# Format messages
formatted_messages = []
counter = 1
for message in message_response.data:
for content in message.content:
if content.type == "text" and 'this is the start of the chat greet the user this chat is about the product with given detail:' not in content.text.value:
sender = 'user' if message.role == 'user' else 'ai'
formatted_messages.append({'sender': sender, 'content': content.text.value, 'id': counter})
counter += 1
paginator = StructurePagination()
paginated_messages = paginator.paginate_queryset(formatted_messages, request)
return paginator.get_paginated_response(paginated_messages)
def post(self, request, pk):
"""
Send a new message in the product chat.
"""
user = request.user
product = get_object_or_404(ProductModel, id=pk)
chat, created = ProductChatModel.objects.get_or_create(user=user, product=product)
if created:
return Response({'detail': 'چت این کاربر با این محصول هنوز ساخته نشده'}, status=status.HTTP_400_BAD_REQUEST)
new_message = request.data.get('new_message', '').strip()
if not new_message:
return Response({'detail': 'پیام جدید نمی‌تواند خالی باشد'}, status=status.HTTP_400_BAD_REQUEST)
client = openai.OpenAI(api_key=settings.OPENAI_API_KEY)
# Send the user message
client.beta.threads.messages.create(
thread_id=chat.thread,
role="user",
content=new_message
)
# Start the assistant's run
run = client.beta.threads.runs.create(thread_id=chat.thread, assistant_id=ASSISTANT_ID)
while run.status != "completed":
run = client.beta.threads.runs.retrieve(thread_id=chat.thread, run_id=run.id)
sleep(1)
# Fetch the updated messages
message_response = client.beta.threads.messages.list(thread_id=chat.thread)
formatted_messages = []
for message in message_response.data:
for content in message.content:
if content.type == "text" and 'this is the start of the chat greet the user this chat is about the product with given detail:' not in content.text.value:
sender = 'user' if message.role == 'user' else 'ai'
formatted_messages.append({'sender': sender, 'content': content.text.value})
formatted_messages = formatted_messages[:2]
return Response(formatted_messages, status=status.HTTP_201_CREATED)
+23 -8
View File
@@ -13,7 +13,7 @@ load_dotenv(".env.local")
DOMAIN = os.getenv("DOMAIN")
API_DOMAIN = os.getenv("API_DOMAIN")
OPENAI_API_KEY = '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'
# TODO update telegram bot token
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
@@ -95,8 +95,8 @@ INSTALLED_APPS = [
# custom apps
'product',
'account',
'entertainment',
'ticket',
'chat',
]
MIDDLEWARE = [
@@ -285,11 +285,11 @@ UNFOLD = {
# esm category model ro lower case bezar inja amir
# {
# "title": _("دسته بندی"),
# "icon": "category",
# "link": reverse_lazy("admin:product_ _changelist"),
# },
{
"title": _("دسته بندی"),
"icon": "category",
"link": reverse_lazy("admin:product_categorymodel_changelist"),
},
{
"title": _("نظرات"),
"icon": "chat",
@@ -306,7 +306,7 @@ UNFOLD = {
"items": [
{
"title": _("users"),
"title": _("کاربران"),
"icon": "person",
"link": reverse_lazy("admin:account_user_changelist"),
},
@@ -314,6 +314,21 @@ UNFOLD = {
],
},
{
"title": _("بخش هوش مصنوعی"),
"separator": True,
"collapsible": True,
"items": [
{
"title": _("چت محصول"),
"icon": "chat",
"link": reverse_lazy("admin:chat_productchatmodel_changelist"),
},
],
},
+2 -1
View File
@@ -16,12 +16,13 @@ urlpatterns = [
path('token/', CustomTokenObtainPairView.as_view(), name='token_obtain_pair'),
path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
# path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
path('admin/', admin.site.urls),
path('schema/', SpectacularAPIView.as_view(), name='schema'),
# path('comment/<int:pk>', views.CommentView.as_view(), name='comment-list'),
path('products/', include('product.urls')),
path('accounts/', include('account.urls')),
path('chat/', include('chat.urls')),
path('tickets/', include('ticket.urls')),
path('', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
]
-50
View File
@@ -1,50 +0,0 @@
from django.contrib import admin
from import_export.admin import ImportExportActionModelAdmin
from unfold.admin import ModelAdmin
from .models import *
@admin.register(Dare)
class Dare(ModelAdmin):
list_display = ['lang1', 'is_for_adults']
list_filter = ['is_for_adults']
@admin.register(Truth)
class Truth(ModelAdmin):
list_display = ['lang1', 'is_for_adults']
list_filter = ['is_for_adults']
@admin.register(Would_you_rather)
class Would_you_rather(ModelAdmin):
list_display = ['lang1', 'is_for_adults']
list_filter = ['is_for_adults']
@admin.register(challenge)
class Challenge(ModelAdmin):
list_display = ['type']
@admin.register(abjad)
class abjad(ModelAdmin):
list_display = ['word', 'difficulty_type', 'answer']
list_filter = ['difficulty_type']
@admin.register(MusicModel)
class MusicAdmin(ModelAdmin):
list_display = ['name', 'message_id', 'singer', 'category', 'trand']
@admin.register(MovieModel)
class MovieAdmin(ModelAdmin):
list_display = ['name', 'message_id', 'category', 'receommended']
@admin.register(MovieCategory)
class MovieCategoryAdmin(ModelAdmin):
pass
@admin.register(MusicCategory)
class MusicCategoryAdmin(ModelAdmin):
pass
-7
View File
@@ -1,7 +0,0 @@
from django.apps import AppConfig
class EntertainmentConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'entertainment'
verbose_name = 'بخش سرگرمی ها'
@@ -1,141 +0,0 @@
# Generated by Django 5.1.2 on 2024-12-13 17:49
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='abjad',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('word', models.TextField(verbose_name='صورت ی سوال')),
('image', models.ImageField(blank=True, null=True, upload_to='media/', verbose_name='عکس بازی افتابه')),
('difficulty_type', models.CharField(choices=[('hard', 'سخت'), ('normal', 'متوسط'), ('easy', 'اسون')], max_length=13, verbose_name='سختی')),
('answer', models.CharField(max_length=30, verbose_name='جواب')),
('option2', models.CharField(blank=True, max_length=30, null=True, verbose_name='گزینه ی اشتباه')),
('option3', models.CharField(blank=True, max_length=30, null=True, verbose_name='گزینه ی اشتباه')),
('option4', models.CharField(blank=True, max_length=30, null=True, verbose_name='گزینه ی اشتباه')),
],
options={
'verbose_name': 'سوال ابجد',
'verbose_name_plural': 'سوالات ابجد',
},
),
migrations.CreateModel(
name='challenge',
fields=[
('type', models.CharField(choices=[('map', 'نقشه ی گنج'), ('prize', 'جوایز')], max_length=30, primary_key=True, serialize=False, verbose_name='نوع چالش')),
('image', models.ImageField(upload_to='media/', verbose_name='عکس')),
('link', models.URLField(verbose_name='لینک')),
('text', models.TextField(verbose_name='متن توضیحات')),
('button_text', models.CharField(max_length=40, verbose_name='متن دکمه')),
],
options={
'verbose_name': 'چالش',
'verbose_name_plural': 'چالش ها',
},
),
migrations.CreateModel(
name='Dare',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('lang1', models.CharField(max_length=200, verbose_name='فارسی')),
('is_for_adults', models.BooleanField(verbose_name='+18 سوال')),
],
options={
'verbose_name': 'شجاعت',
'verbose_name_plural': 'شجاعت ها',
},
),
migrations.CreateModel(
name='MovieCategory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=40, verbose_name='نام دسته بندی')),
],
options={
'verbose_name': 'دسته بندی قیلم',
'verbose_name_plural': 'دسته بندی فیلم ها',
},
),
migrations.CreateModel(
name='UploadParent',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=300, verbose_name='نام')),
('message_id', models.CharField(max_length=40, verbose_name='ای دی پیام تلگرام')),
],
),
migrations.CreateModel(
name='MusicCategory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=40, verbose_name='نام دسته بندی')),
],
options={
'verbose_name': 'دسته بندی موزیک',
'verbose_name_plural': 'دسته بندی موزیک ها',
},
),
migrations.CreateModel(
name='Truth',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('lang1', models.CharField(max_length=200, verbose_name='فارسی')),
('is_for_adults', models.BooleanField(verbose_name='+18 سوال')),
],
options={
'verbose_name': 'حقیقت',
'verbose_name_plural': 'حقیقت ها',
},
),
migrations.CreateModel(
name='Would_you_rather',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('lang1', models.CharField(max_length=200, verbose_name='فارسی')),
('is_for_adults', models.BooleanField(verbose_name='+18 سوال')),
],
options={
'verbose_name': 'ترجیح میدی',
'verbose_name_plural': 'ترجیح میدی ها',
},
),
migrations.CreateModel(
name='MovieModel',
fields=[
('uploadparent_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='entertainment.uploadparent')),
('description', models.CharField(blank=True, max_length=4000, null=True, verbose_name='توضیحات فیلم')),
('receommended', models.BooleanField(default=False, verbose_name='پیشنهادی')),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='entertainment.moviecategory', verbose_name='دسته بندی')),
],
options={
'verbose_name': 'مدل فیلم',
'verbose_name_plural': 'مدل فیلم ها',
},
bases=('entertainment.uploadparent',),
),
migrations.CreateModel(
name='MusicModel',
fields=[
('uploadparent_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='entertainment.uploadparent')),
('lyric', models.CharField(blank=True, max_length=4000, null=True, verbose_name='متن اهنگ')),
('singer', models.CharField(blank=True, max_length=300, null=True, verbose_name='خواننده')),
('trand', models.BooleanField(default=False, verbose_name='ترند')),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='entertainment.musiccategory', verbose_name='دسته بندی')),
],
options={
'verbose_name': 'مدل اهنگ',
'verbose_name_plural': 'مدل اهنگ ها',
},
bases=('entertainment.uploadparent',),
),
]
-142
View File
@@ -1,142 +0,0 @@
from django.db import models
class BaseModel(models.Model):
lang1 = models.CharField(max_length=200, verbose_name='فارسی')
is_for_adults = models.BooleanField(verbose_name='+18 سوال')
class Meta:
abstract = True
def __str__(self):
return self.lang1
async def get(self, *attrs):
if len(attrs) == 1:
return getattr(self, attrs[0], None)
else:
return tuple(getattr(self, attr, None) for attr in attrs)
class Dare(BaseModel):
class Meta:
verbose_name = 'شجاعت'
verbose_name_plural = 'شجاعت ها'
class Truth(BaseModel):
class Meta:
verbose_name = 'حقیقت'
verbose_name_plural = "حقیقت ها"
class Would_you_rather(BaseModel):
class Meta:
verbose_name = 'ترجیح میدی'
verbose_name_plural = "ترجیح میدی ها"
class challenge(models.Model):
challenge_type = (
('map', 'نقشه ی گنج'),
('prize', 'جوایز')
)
type = models.CharField(max_length=30, choices=challenge_type, verbose_name='نوع چالش', primary_key=True)
image = models.ImageField(upload_to='media/', verbose_name='عکس')
link = models.URLField(verbose_name='لینک')
text = models.TextField(verbose_name='متن توضیحات')
button_text = models.CharField(max_length=40, verbose_name='متن دکمه')
def __str__(self):
return f'{self.type} - {self.text[0:50]}'
class Meta:
verbose_name = 'چالش'
verbose_name_plural = 'چالش ها'
class abjad(models.Model):
word = models.TextField(verbose_name='صورت ی سوال')
difficulty = (
('hard', 'سخت'),
('normal', 'متوسط'),
('easy', 'اسون')
)
image = models.ImageField(upload_to='media/', verbose_name='عکس بازی افتابه', blank=True, null=True)
difficulty_type = models.CharField(max_length=13, choices=difficulty, verbose_name='سختی')
answer = models.CharField(max_length=30, verbose_name='جواب')
option2 = models.CharField(max_length=30, verbose_name='گزینه ی اشتباه', null=True, blank=True)
option3 = models.CharField(max_length=30, verbose_name='گزینه ی اشتباه', null=True, blank=True)
option4 = models.CharField(max_length=30, verbose_name='گزینه ی اشتباه', null=True, blank=True)
def __str__(self):
return f'{self.word}'
class Meta:
verbose_name = 'سوال ابجد'
verbose_name_plural = 'سوالات ابجد'
async def get(self, *attrs):
if len(attrs) == 1:
return getattr(self, attrs[0], None)
else:
return tuple(getattr(self, attr, None) for attr in attrs)
GAME_DATA = {
Dare: {
'button': 'dare',
'game_name': 'شجاعت'
},
Truth: {
'button': 'truth',
'game_name': 'حقیقت'
},
Would_you_rather: {
'button': 'wyr',
'game_name': 'ترجیح میدی'
}
}
from django.db import models
class MusicCategory(models.Model):
name = models.CharField(max_length=40, verbose_name='نام دسته بندی')
def __str__(self):
return self.name
class Meta:
verbose_name = 'دسته بندی موزیک'
verbose_name_plural = 'دسته بندی موزیک ها'
class MovieCategory(models.Model):
name = models.CharField(max_length=40, verbose_name='نام دسته بندی')
def __str__(self):
return self.name
class Meta:
verbose_name = 'دسته بندی قیلم'
verbose_name_plural = 'دسته بندی فیلم ها'
class UploadParent(models.Model):
name = models.CharField(max_length=300, verbose_name='نام')
message_id = models.CharField(max_length=40, verbose_name='ای دی پیام تلگرام')
def __str__(self):
return self.name
class MusicModel(UploadParent):
lyric = models.CharField(verbose_name='متن اهنگ', max_length=4000, blank=True, null=True)
singer = models.CharField(max_length=300, verbose_name='خواننده', blank=True, null=True)
category = models.ForeignKey(MusicCategory, on_delete=models.CASCADE, verbose_name='دسته بندی', blank=True, null=True)
trand = models.BooleanField(default=False, verbose_name='ترند')
class Meta:
verbose_name = 'مدل اهنگ'
verbose_name_plural = 'مدل اهنگ ها'
class MovieModel(UploadParent):
description = models.CharField(max_length=4000,verbose_name='توضیحات فیلم', blank=True, null=True)
category = models.ForeignKey(MovieCategory, on_delete=models.CASCADE, verbose_name='دسته بندی', blank=True, null=True)
receommended = models.BooleanField(default=False, verbose_name='پیشنهادی')
class Meta:
verbose_name = 'مدل فیلم'
verbose_name_plural = 'مدل فیلم ها'
-3
View File
@@ -1,3 +0,0 @@
from django.shortcuts import render
# Create your views here.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

+3
View File
@@ -7,6 +7,9 @@ from unfold.admin import ModelAdmin
class ProductModelAdmin(ModelAdmin):
pass
@admin.register(CategoryModel)
class CategoryModelAdmin(ModelAdmin):
pass
@admin.register(CommentModel)
+4 -1
View File
@@ -5,7 +5,10 @@ class ProductSerializer(serializers.ModelSerializer):
class Meta:
model = ProductModel
fields = "__all__"
class ProductChatSerializer(serializers.ModelSerializer):
class Meta:
model = ProductModel
fields = ['name', 'description', 'price', 'in_stock', 'discount', ]
class CommentSerializer(serializers.ModelSerializer):
class Meta:
+9
View File
@@ -1,6 +1,7 @@
aiohappyeyeballs==2.4.0
aiohttp==3.10.5
aiosignal==1.3.1
annotated-types==0.7.0
anyio==4.6.0
asgiref==3.8.1
attrs==24.2.0
@@ -10,6 +11,7 @@ charset-normalizer==3.3.2
cryptography==43.0.3
defusedxml==0.8.0rc2
diff-match-patch==20230430
distro==1.9.0
Django==5.1.2
django-cleanup==8.1.0
django-cors-headers==4.4.0
@@ -27,6 +29,7 @@ Faker==28.4.1
frozenlist==1.4.1
geoip2==4.8.0
ghasedak_sms==1.0.3
ghasedakpack==0.1.13
gnupg==2.3.1
h11==0.14.0
httpagentparser==1.9.5
@@ -36,15 +39,19 @@ idna==3.10
inflection==0.5.1
jalali_core==1.0.0
jdatetime==5.0.0
jiter==0.8.2
jsonschema==4.23.0
jsonschema-specifications==2024.10.1
maxminddb==2.6.2
multidict==6.1.0
oauthlib==3.2.2
openai==1.58.1
pillow==10.4.0
psutil==6.0.0
psycopg2-binary==2.9.10
pycparser==2.22
pydantic==2.10.4
pydantic_core==2.27.2
PyJWT==2.9.0
pyTelegramBotAPI==4.23.0
python-dateutil==2.9.0.post0
@@ -64,6 +71,8 @@ social-auth-app-django==5.4.2
social-auth-core==4.5.4
sqlparse==0.5.1
tablib==3.5.0
tqdm==4.67.1
typing_extensions==4.12.2
tzdata==2024.1
uritemplate==4.1.1
urllib3==2.2.3
-2
View File
@@ -46,5 +46,3 @@ otp_input = ghasedak_sms.SendOtpInput(
# Send the OTP SMS
response = sms_api.send_otp_sms(otp_input)
# Print the response to check the result
print(response)
+2 -2
View File
@@ -3,7 +3,7 @@ services:
build:
context: ./frontend
ports:
- "80:3000"
- "3000:3000"
depends_on:
- django
networks:
@@ -13,7 +13,7 @@ services:
build:
context: ./backend
ports:
- "8000:8000"
- "8001:8000"
depends_on:
- db
volumes:
+11 -4
View File
@@ -5,11 +5,18 @@ import { VueQueryDevtools } from "@tanstack/vue-query-devtools";
<template>
<div>
<NuxtRouteAnnouncer />
<NuxtLayout>
<NuxtPage />
<div dir="ltr">
<VueQueryDevtools dir="ltr" buttonPosition="bottom-left" />
</div>
<ToastProvider>
<NuxtPage />
<div dir="ltr">
<VueQueryDevtools dir="ltr" buttonPosition="bottom-left" />
</div>
<ToastContainer />
<ToastViewport
class="[--viewport-padding:_25px] fixed bottom-0 left-0 flex flex-col p-[var(--viewport-padding)] gap-[10px] w-[390px] max-w-[100vw] m-0 list-none z-[2147483647] outline-none"
/>
</ToastProvider>
</NuxtLayout>
</div>
</template>
+84
View File
@@ -0,0 +1,84 @@
/*
Zoom animation
*/
.zoom-leave-active,
.zoom-enter-active {
transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out;
}
.zoom-enter-to,
.zoom-leave-from {
opacity: 1;
transform: scale(1) translateY(0px);
}
.zoom-enter-from,
.zoom-leave-to {
opacity: 0;
transform: scale(0.95) translateY(15px);
}
/*
Fade animation
*/
.fade-leave-active,
.fade-enter-active {
transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out;
}
.fade-enter-to,
.fade-leave-from {
opacity: 1;
transform: scale(1);
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: scale(0.95);
}
/*
FadeDown animation
*/
.fade-down-leave-active,
.fade-down-enter-active {
transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out;
}
.fade-down-enter-to,
.fade-down-leave-from {
opacity: 1;
transform: translateY(0px) scale(1);
}
.fade-down-enter-from,
.fade-down-leave-to {
opacity: 0;
transform: translateY(10px) scale(0.95);
}
/*
FadeRightToLeft animation
*/
.fade-right-to-left-leave-active,
.fade-right-to-left-enter-active {
transform-origin: bottom right;
transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out;
}
.fade-right-to-left-enter-to,
.fade-right-to-left-leave-from {
opacity: 1;
transform: translateX(0px) scale(1);
}
.fade-right-to-left-enter-from,
.fade-right-to-left-leave-to {
opacity: 0;
transform: translateX(30px) scale(0.95);
}
+6 -6
View File
@@ -36,10 +36,10 @@
}
&:disabled {
@apply bg-slate-50 text-slate-300;
@apply bg-slate-100 text-slate-400;
svg[class~=iconify] path {
@apply stroke-slate-300;
@apply stroke-slate-400;
}
}
}
@@ -57,10 +57,10 @@
}
&:disabled {
@apply bg-slate-100 text-slate-300;
@apply bg-slate-100 text-slate-400;
svg[class~=iconify] path {
@apply stroke-slate-300;
@apply stroke-slate-400;
}
}
}
@@ -103,10 +103,10 @@
}
&:disabled {
@apply text-slate-300;
@apply text-slate-400;
svg[class~=iconify] path {
@apply stroke-slate-300;
@apply stroke-slate-400;
}
}
}
+37 -1
View File
@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "./animations.css";
@import "./other.utils.css";
@import "./typo.utils.css";
@import "./button.comp.css";
@@ -120,12 +121,16 @@
--breakpoint-xs: 480px;
/* ANIMATIONS */
--animate-marquee: marquee 20s linear infinite;
--animate-marquee: marquee 3s linear infinite;
--animate-slide-down: slideDown 300ms ease-out;
--animate-slide-up: slideUp 300ms ease-out;
--animate-overlay-show: overlayShow 150ms ease-in;
--animate-content-show: contentShow 150ms ease-in;
--animate-toast-hide: toastHide 100ms ease-in;
--animate-toast-in: toastSlideIn 600ms cubic-bezier(0.16, 1, 0.3, 1);
--animate-toast-out: toastSlideOut 200ms ease-out;
@keyframes marquee {
to {
transform: translateX(50%);
@@ -169,6 +174,37 @@
transform: scale(1);
}
}
@keyframes toastHide {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes toastSlideIn {
from {
opacity: 0;
transform: translateX(calc(100% + var(--viewport-padding)));
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes toastSlideOut {
from {
opacity: 1;
transform: translateX(var(--reka-toast-swipe-end-x));
}
to {
opacity: 0;
transform: translateX(calc(100% + var(--viewport-padding)));
}
}
}
/* CONTAINER */
+21 -4
View File
@@ -2,31 +2,47 @@
// types
import { useImageColor } from "~/composables/useImageColor";
type Props = {
id: number,
category: string;
count: number;
description: string;
picture: string;
darkLayer?: boolean;
}
// props
defineProps<Props>();
const props = defineProps<Props>();
const { id } = toRefs(props);
// state
const { colorObject } = useImageColor(`#category-image-${id.value}`);
</script>
<template>
<div class="relative rounded-150 overflow-hidden w-full h-[500px]">
<img
:id="`category-image-${id}`"
class="absolute object-cover size-full"
:src="picture"
alt=""
/>
<div class="bg-linear-to-t from-black/80 to-transparent absolute z-10 size-full" />
<div
v-if="darkLayer"
class="bg-linear-to-t from-black/50 to-transparent to-40% absolute z-10 size-full"
/>
<div class="absolute z-20 bottom-0 p-6 flex items-end justify-between w-full">
<div class="flex flex-col gap-2 text-white">
<div
:class="(colorObject?.isLight && !darkLayer) ? 'text-black' : 'text-white'"
class="flex flex-col gap-2"
>
<div class="typo-s-h-md">
تمام دسته ها
<span class="typo-p-xs -translate-y-1 inline-block mr-1">
@@ -39,7 +55,8 @@ defineProps<Props>();
<Icon
size="24"
name="ci:arrow-left"
class="**:stroke-white mb-1"
class="mb-1"
:class="(colorObject?.isLight && !darkLayer) ? '**:stroke-black' : '**:stroke-white'"
/>
</div>
@@ -34,27 +34,29 @@ const highlights = ref<Highlight[]>([
</script>
<template>
<section class="w-full flex-center py-[5rem] gap-[1.25rem] container">
<template v-for="(highlight, index) in highlights" :key="index">
<div class="flex flex-col-center gap-[.75rem] w-1/4 px-5">
<div class="size-[1.5rem] flex-center">
<Icon :name="highlight.icon" />
</div>
<div class="w-full flex-col-center gap-[.25rem]">
<section class="w-full border-t-[0.5px] border-slate-200 mt-20">
<div class="w-full flex-center py-[5rem] gap-[1.25rem] container">
<template v-for="(highlight, index) in highlights" :key="index">
<div class="flex flex-col-center gap-[.75rem] w-1/4 px-5">
<div class="size-[1.5rem] flex-center">
<Icon :name="highlight.icon" />
</div>
<div class="w-full flex-col-center gap-[.25rem]">
<span class="typo-sub-h-md text-black text-center">
{{ highlight.title }}
</span>
<p class="text-slate-500 typo-p-md text-center">
{{ highlight.description }}
</p>
<p class="text-slate-500 typo-p-sm mt-1 text-center">
{{ highlight.description }}
</p>
</div>
</div>
</div>
<div
class="w-[1px] h-[5rem] bg-slate-200"
v-if="index + 1 != highlights.length"
/>
</template>
<div
class="w-[1px] h-[5rem] bg-slate-200"
v-if="index + 1 != highlights.length"
/>
</template>
</div>
</section>
</template>
+1 -1
View File
@@ -12,7 +12,7 @@ const {} = toRefs(props);
</script>
<template>
<section class="h-[100svh] mt-20 px-20">
<section class="mt-20 px-20">
<div class="flex items-center justify-between mb-20">
<span class="typo-h-4 text-black">
مقالات اخیر سایت
@@ -0,0 +1,40 @@
<script setup lang="ts">
// types
type Props = {
circle?: boolean,
size?: number
}
// props
const props = withDefaults(defineProps<Props>(), {
size: 200
});
const { circle } = toRefs(props);
</script>
<template>
<div
:style="{
height: `${size}px`,
width: circle ? `${size}px` : '100%',
}"
class="relative flex items-center w-full justify-center shrink-0"
:class="{
'rounded-full overflow-hidden': circle,
}"
>
<img
:style="{
maskImage: 'radial-gradient(black, transparent 70%)'
}"
src="/public/img/ai-loading-2.gif"
class="size-full object-cover absolute pt-2"
alt="ai-loading"
/>
</div>
</template>
@@ -0,0 +1,174 @@
<script setup lang="ts">
// import
import AiLoading from "~/components/product/ChatBox/AiLoading.vue";
import useGetChat from "~/composables/api/chat/useGetChat";
import ChatInput from "~/components/product/ChatBox/ChatInput.vue";
import { useInfiniteScroll, useScroll, useScrollLock, whenever } from "@vueuse/core";
import { useIsMutating } from "@tanstack/vue-query";
import { MUTATION_KEYS } from "~/constants";
import CloseButton from "~/components/product/ChatBox/CloseButton.vue";
// provide-inject
const { isOpen } = inject("isOpen") as any;
// state
const id = ref(1);
const chatContainerEl = ref<HTMLElement | null>(null);
const lastMessageBeforeUpdate = ref(0);
const {
data: chat,
isPending: isChatPending,
isFetchingNextPage: isNextChatPagePending,
hasNextPage: hasMoreChat,
fetchNextPage: loadMoreChat
} = useGetChat(id, isOpen);
const isCreateMessagePending = useIsMutating({
mutationKey: [MUTATION_KEYS.create_chat]
});
const canLoadMoreChat = ref(false);
const isChatScrollLocked = useScrollLock(chatContainerEl);
const { y: chatContainerScrollY } = useScroll(chatContainerEl, {
behavior: "smooth"
});
useInfiniteScroll(
chatContainerEl,
async () => {
if (hasMoreChat.value && !isChatPending.value) {
lastMessageBeforeUpdate.value = chatMessages.value ? chatMessages.value[0].id : 0;
await loadMoreChat();
}
},
{
distance: 10,
direction: "top",
throttle: 1000,
canLoadMore: () => canLoadMoreChat.value
}
);
// method
const scrollToBottom = () => {
chatContainerScrollY.value = chatContainerEl.value?.scrollHeight ?? 0;
};
// computed
const chatMessages = computed(() => {
return chat.value?.pages.flatMap(page => {
return page.results;
}).reverse();
});
// watch
watch(() => isCreateMessagePending.value, (newValue) => {
requestAnimationFrame(() => {
scrollToBottom();
isChatScrollLocked.value = !!newValue;
});
});
watch(() => chat.value, () => {
if (canLoadMoreChat.value) {
const scrollTopOld = chatContainerEl.value!.scrollTop - 100;
requestAnimationFrame(() => {
const lastChatMessageEl = document.querySelector(`#message-container-${lastMessageBeforeUpdate.value}`) as HTMLElement;
lastChatMessageEl?.scrollIntoView();
chatContainerEl.value!.scrollTop = chatContainerEl.value!.scrollTop + scrollTopOld;
});
}
});
whenever(() => !!chatContainerEl.value, () => {
requestAnimationFrame(() => {
scrollToBottom();
});
setTimeout(() => {
canLoadMoreChat.value = true;
}, 2000);
}, {
once: true
});
</script>
<template>
<Transition name="fade-right-to-left">
<div
v-if="isOpen"
class="fixed right-8 bottom-8 w-[450px] transition-all duration-500 overflow-hidden h-[700px] rounded-250 shadow-2xl shadow-black/30 pt-[40px] bg-white"
>
<CloseButton
:disabled="!!isCreateMessagePending"
/>
<Transition
name="zoom"
mode="out-in"
>
<div
v-if="!isChatPending"
class="p-4.5 h-full flex flex-col justify-between gap-4"
>
<div
:style="{
maskImage: 'linear-gradient(to top, transparent, black 5%, black, black)'
}"
class="hide-scrollbar flex flex-col py-7 gap-6 h-full overflow-y-auto"
ref="chatContainerEl"
>
<div v-if="hasMoreChat" class="py-2 flex items-center justify-center">
<Icon
name="svg-spinners:3-dots-fade"
size="24"
/>
</div>
<ChatMessage
v-for="(message, index) in chatMessages"
:key="message.id"
:id="message.id"
:reverse="message.sender === 'ai'"
:content="message.content"
:isLast="chatMessages?.length === index + 1"
@textUpdate="scrollToBottom"
/>
<ChatMessage
v-if="!!isCreateMessagePending"
:id="Date.now() + 1"
reverse
content=""
isLast
@textUpdate="scrollToBottom"
:loadingContent="!!isCreateMessagePending"
/>
</div>
<ChatInput />
</div>
<div
v-else
class="w-full h-full flex items-center justify-center absolute inset-0"
>
<AiLoading />
</div>
</Transition>
</div>
</Transition>
</template>
@@ -0,0 +1,45 @@
<script setup lang="ts">
// types
type Props = {}
// props
const props = defineProps<Props>();
const {} = toRefs(props);
// state
const isOpen = ref(false);
// method
const closeChat = () => isOpen.value = false;
// provide-inject
provide("isOpen", {
isOpen,
closeChat
});
</script>
<template>
<button
v-if="!isOpen"
@click="isOpen = !isOpen"
class="cursor-pointer fixed shadow-xl shadow-black/30 right-8 bottom-8 bg-black size-[70px] flex justify-center items-center rounded-full"
>
<Icon
name="streamline:artificial-intelligence-spark"
class="**:stroke-white"
size="26"
/>
</button>
<ChatBoxContainer :isOpen="isOpen" />
</template>
@@ -0,0 +1,305 @@
<script setup lang="ts">
// types
import AiLoading from "~/components/product/ChatBox/AiLoading.vue";
import useCreateChatMessage from "~/composables/api/chat/useCreateChatMessage";
// state
const { $queryClient: queryClient } = useNuxtApp();
const { addToast } = useToast();
const { mutateAsync: createMessage, isPending: isCreatingMessage } = useCreateChatMessage(queryClient);
const chatInputEl = ref<HTMLInputElement | null>(null);
// method
const sendMessage = async () => {
const value = chatInputEl.value!.value;
if (value && value.length > 0) {
try {
chatInputEl.value!.value = "";
await createMessage({
new_message: value,
productId: 1
});
} catch (e) {
addToast({
message: "مشکلی پیش آمده",
options: {
status: "error"
}
});
}
}
};
</script>
<template>
<div class="relative">
<div id="poda" class="poda-rotate">
<div
class="glow w-full"
:class="isCreatingMessage ? '' : '!opacity-0'"
/>
<div
class="darkBorderBg w-full"
:class="isCreatingMessage ? '' : '!opacity-0'"
/>
<div
class="darkBorderBg w-full"
:class="isCreatingMessage ? '' : '!opacity-0'"
/>
<div
class="darkBorderBg w-full"
:class="isCreatingMessage ? '' : '!opacity-0'"
/>
<div
class="white w-full"
:class="isCreatingMessage ? '' : '!opacity-0'"
/>
<form
class="transition-all duration-200 relative flex items-center gap-4 w-full shadow-sm rounded-full h-[56px] border pe-3 ps-4"
:class="isCreatingMessage ? 'border-transparent shadow-black/10 bg-white/85 backdrop-blur-xl' : 'border-slate-200 shadow-transparent bg-white'"
>
<input
ref="chatInputEl"
:disabled="isCreatingMessage"
:placeholder="isCreatingMessage ? 'دارم فکر میکنم...' : 'سوال خود را بپرسید'"
type="text"
name="text"
class="focus:outline-none h-full typo-p-sm w-full border-none"
/>
<button
type="submit"
:disabled="isCreatingMessage"
@click="sendMessage"
class="disabled:cursor-default cursor-pointer outline-none transition-all duration-350 size-[35px] shrink-0 rounded-full flex items-center justify-center"
:class="isCreatingMessage ? 'bg-transparent' : 'bg-black'"
>
<TransitionGroup name="fade-down">
<AiLoading v-if="isCreatingMessage" circle :size="75" class="mb-1" />
<Icon v-else name="iconamoon:send-light" class="absolute **:stroke-white" />
</TransitionGroup>
</button>
</form>
</div>
</div>
</template>
<style>
.white,
.darkBorderBg,
.glow {
max-height: 70px;
/* max-width: 314px; */
height: 100%;
width: 100%;
position: absolute;
overflow: hidden;
z-index: -1;
/* Border Radius */
border-radius: 99999px;
filter: blur(3px);
opacity: 0.6;
transition: opacity 0.4s;
}
#poda {
display: flex;
align-items: center;
justify-content: center;
}
.white {
max-height: 63px;
max-width: 100%;
border-radius: 10px;
filter: blur(2px);
}
.white::before {
content: "";
z-index: -2;
text-align: center;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(83deg);
position: absolute;
width: 600px;
height: 600px;
background-repeat: no-repeat;
background-position: 0 0;
filter: brightness(1.4);
background-image: conic-gradient(
transparent 0%,
#a099d8,
transparent 8%,
transparent 50%,
#dfa2da,
transparent 58%
);
/* animation: rotate 4s linear infinite; */
transition: all 2s;
}
.darkBorderBg {
max-height: 65px;
max-width: 100%;
}
.darkBorderBg::before {
content: "";
z-index: -2;
text-align: center;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(82deg);
position: absolute;
width: 600px;
height: 600px;
background-repeat: no-repeat;
background-position: 0 0;
background-image: conic-gradient(
transparent,
#998fdc,
transparent 10%,
transparent 50%,
#cf7bba,
transparent 60%
);
transition: all 2s;
}
/*
#poda:hover > .darkBorderBg::before {
transform: translate(-50%, -50%) rotate(262deg);
}
#poda:hover > .glow::before {
transform: translate(-50%, -50%) rotate(240deg);
}
#poda:hover > .white::before {
transform: translate(-50%, -50%) rotate(263deg);
}
#poda:hover > .border-input::before {
transform: translate(-50%, -50%) rotate(250deg);
}
#poda:hover > .darkBorderBg::before {
transform: translate(-50%, -50%) rotate(-98deg);
}
#poda:hover > .glow::before {
transform: translate(-50%, -50%) rotate(-120deg);
}
#poda:hover > .white::before {
transform: translate(-50%, -50%) rotate(-97deg);
}
#poda:hover > .border-input::before {
transform: translate(-50%, -50%) rotate(-110deg);
}
*/
.poda-rotate > .darkBorderBg::before {
animation: rotate-dark-border-bg 2.5s 0.1s linear infinite;
}
.poda-rotate > .glow::before {
animation: rotate-glow 2.5s 0.1s linear infinite;
}
.poda-rotate > .white::before {
animation: rotate-white 2.5s 0.1s linear infinite;
}
/*
#poda:focus-within > .darkBorderBg::before {
transform: translate(-50%, -50%) rotate(442deg);
transition: all 4s;
}
#poda:focus-within > .glow::before {
transform: translate(-50%, -50%) rotate(420deg);
transition: all 4s;
}
#poda:focus-within > .white::before {
transform: translate(-50%, -50%) rotate(443deg);
transition: all 4s;
}
#poda:focus-within > .border-input::before {
transform: translate(-50%, -50%) rotate(430deg);
transition: all 4s;
}
*/
.glow {
overflow: hidden;
filter: blur(30px);
opacity: 0.4;
max-height: 130px;
max-width: 354px;
}
.glow:before {
content: "";
z-index: -2;
text-align: center;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(60deg);
position: absolute;
width: 999px;
height: 999px;
background-repeat: no-repeat;
background-position: 0 0;
/*border color, change middle color*/
background-image: conic-gradient(
transparent,
#998fdc 5%,
transparent 38%,
transparent 50%,
#cf7bba 60%,
transparent 87%
);
/* change speed here */
/* animation: rotate 4s 0.3s linear infinite; */
transition: all 2s;
}
@keyframes rotate-dark-border-bg {
100% {
transform: translate(-50%, -50%) rotate(442deg);
}
}
@keyframes rotate-glow {
100% {
transform: translate(-50%, -50%) rotate(420deg);
}
}
@keyframes rotate-white {
100% {
transform: translate(-50%, -50%) rotate(443deg);
}
}
</style>
@@ -0,0 +1,102 @@
<script setup lang="ts">
// types
type Props = {
id: number,
reverse?: boolean,
content: string,
isLast?: boolean,
loadingContent?: boolean
}
// props
const props = defineProps<Props>();
const { reverse, id, isLast, content } = toRefs(props);
// emit
const emit = defineEmits(["textUpdate"]);
// state
const { $gsap: gsap } = useNuxtApp();
// method
const showMessage = () => {
gsap.fromTo(`#message-container-${id.value}`, {
rotateX: -50,
translateY: -40,
opacity: 0
}, {
rotateX: 0,
translateY: 0,
opacity: 1,
duration: 0.5,
ease: "expo.out"
});
};
// lifecycle
onMounted(() => {
if (isLast.value) {
showMessage();
}
if (reverse.value && isLast.value) {
gsap.fromTo(`#chat-message-content-${id.value}`, {
text: "",
duration: 2.5,
ease: "none"
}, {
text: { value: content.value, rtl: false },
duration: 2.5,
ease: "none",
onUpdate: () => emit("textUpdate")
});
}
});
</script>
<template>
<div class="perspective-near">
<div
:id="`message-container-${id}`"
class="flex gap-2.5 origin-top w-full"
:class="reverse ? 'flex-row-reverse' : ''"
>
<div
class="relative overflow-hidden flex items-center justify-center mt-px bg-slate-300 rounded-full size-[35px] shrink-0"
>
<img
v-if="!reverse"
src="/public/img/hero-bg.jpg"
class="size-full object-cover absolute"
alt="profile"
/>
</div>
<div
class="rounded-150 px-4 py-3"
:class="reverse ? 'bg-slate-100 text-slate-600' : 'bg-black text-white'"
>
<p
v-if="!loadingContent"
:id="`chat-message-content-${id}`"
class="typo-p-sm font-normal whitespace-pre-wrap"
>
{{ content }}
</p>
<Icon
v-else
name="svg-spinners:3-dots-move"
size="20"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
// types
type Props = {
disabled?: boolean,
}
// props
const props = defineProps<Props>();
const {} = toRefs(props);
// provide-inject
const { closeChat } = inject("isOpen") as any;
</script>
<template>
<div
class="absolute bg-white border-b border-slate-100 px-5 h-[60px] top-0 z-20 flex justify-between w-full items-center">
<span class="typo-p-sm">
چت بات هوش مصنوعی
</span>
<button
@click="closeChat"
:disabled="disabled"
class="hover:bg-slate-100 transition-all disabled:cursor-default cursor-pointer rounded-full size-[25px] flex items-center justify-center"
>
<Icon name="iconamoon:sign-times-duotone" class="**:stroke-slate-900" />
</button>
</div>
</template>
+5 -3
View File
@@ -5,6 +5,7 @@ type Props = {
size?: "xl" | "lg" | "md";
startIcon?: string;
endIcon?: string;
loading?: boolean;
};
// props
@@ -35,8 +36,9 @@ const classes = computed(() => {
<template>
<button :class="classes">
<Icon v-if="startIcon" :name="startIcon" />
<slot />
<Icon v-if="endIcon" :name="endIcon" />
<Icon v-if="!loading && startIcon" :name="startIcon" />
<slot v-if="!loading" />
<Icon v-if="!loading && endIcon" :name="endIcon" />
<Icon v-if="loading" name="svg-spinners:3-dots-fade" class="my-0.5" />
</button>
</template>
+25 -10
View File
@@ -1,51 +1,66 @@
<script setup lang="ts">
// types
import Tooltip from "~/components/ui/Tooltip.vue";
type Props = {
variant?: "solid" | "outlined";
startIcon?: string;
endIcon?: string;
disabled?: boolean;
error?: boolean;
message?: string;
placeholder?: string;
modelValue?: string;
};
// props
const props = withDefaults(defineProps<Props>(), {
variant: "solid",
variant: "solid"
});
const { variant, message, error, disabled } = toRefs(props);
// emits
const emit = defineEmits(["update:modelValue"]);
// state
const inputRef = ref<HTMLInputElement | null>(null);
// computed
const classes = computed(() => {
return [
"flex items-center cursor-text transition-all border-[1.5px] gap-3 typo-label-md p-4 rounded-100",
"flex items-center cursor-text transition-all border-[1.5px] gap-3 typo-label-md px-4 py-3 rounded-100",
{
"input-solid": variant.value === "solid",
"input-outlined": variant.value === "outlined",
"input-effects": !error.value,
[variant.value === "solid"
? "input-solid-error"
: "input-outlined-error"]: error.value,
},
: "input-outlined-error"]: error.value
}
];
});
// methods
const onInput = (e: any) => {
emit("update:modelValue", e.target.value);
};
</script>
<template>
<div v-bind="$attrs" :class="classes" @click="inputRef?.focus()">
<Icon v-if="startIcon" :name="startIcon" class="ms-0" size="24px" />
<slot name="startItem" />
<input
:value="modelValue"
@input="onInput"
ref="inputRef"
class="outline-none w-max"
:placeholder="placeholder"
/>
<Icon v-if="endIcon" :name="endIcon" class="me-0" size="24px" />
<slot name="endItem" />
</div>
<!-- <Tooltip :title="message" class="w-full">
</Tooltip> -->
+138
View File
@@ -0,0 +1,138 @@
<script setup lang="ts">
// types
type Props = {
status?: "success" | "error" | "idle";
modelValue: never[];
autofocus?: boolean;
disabled?: boolean;
}
// props
const props = withDefaults(defineProps<Props>(), {
status: "idle"
});
const { modelValue, disabled, status } = toRefs(props);
// state
const { $gsap: gsap } = useNuxtApp();
// emit
const emit = defineEmits(["complete", "update:modelValue"]);
// state
const currentOtpCode = ref([]);
// methods
const handleChange = () => {
emit("update:modelValue", currentOtpCode.value);
};
const handleComplete = () => {
emit("update:modelValue", currentOtpCode.value);
emit("complete", currentOtpCode.value);
};
const playStatusAnimation = () => {
const inputCount = 6;
const duration = 0.100;
let statusColor = {
border : "",
bg : ""
};
if (status.value === "success") {
statusColor.border = "var(--color-success-500)";
statusColor.bg = "var(--color-success-50)";
} else if (status.value === "error") {
statusColor.border = "var(--color-danger-500)";
statusColor.bg = "var(--color-danger-50)";
}
let index = 0;
const animate = (index: number) => {
setTimeout(() => {
gsap.to(`#otp-input-${index}`, {
borderColor: statusColor.border,
backgroundColor: statusColor.bg,
scale: 1.2,
duration: duration / 2
});
gsap.to(`#otp-input-${index}`, {
scale: 1,
duration: duration / 2,
delay: duration
});
setTimeout(() => {
gsap.to(`#otp-input-${index}`, {
borderColor: "black",
backgroundColor: "var(--color-slate-50)"
});
}, (inputCount + 1) * duration * 3000);
}, index * duration * 500);
};
while (index < 6) {
animate(index);
index++;
}
};
// watch
watch(() => modelValue.value, (value) => {
currentOtpCode.value = value;
});
watch(() => disabled.value, (value) => {
if (!value) {
const otpInputFirst = document.querySelector("#otp-input-0") as HTMLInputElement;
setTimeout(() => {
otpInputFirst.focus();
}, 100);
}
});
watch(() => status.value, (value) => {
if (value !== "idle") {
playStatusAnimation();
}
});
</script>
<template>
<div>
<PinInputRoot
:disabled="disabled"
v-bind="$attrs"
type="number"
v-model="currentOtpCode"
placeholder="_"
class="flex gap-4 items-center justify-center mt-1"
@change="handleChange"
@complete="handleComplete"
otp
>
<PinInputInput
v-for="(id, index) in 6"
:id="`otp-input-${index}`"
:key="id"
:index="index"
:autofocus="autofocus ? index === 0 ? true : 'off' : 'off'"
class="disabled:text-slate-400 focus-within:border-black transition-all size-16 bg-slate-50 typo-label-lg rounded-lg text-center border-[1.5px] border-slate-200 outline-none"
/>
</PinInputRoot>
</div>
</template>
@@ -0,0 +1,108 @@
<script setup lang="ts">
// import
import type { ToastOptions } from "~/composables/useToast";
// type
type Props = {
id: number;
message: string,
options: ToastOptions
}
// props
const props = defineProps<Props>();
const { id, options } = toRefs(props);
// state
const { destroyToast } = useToast();
const open = ref(true);
// method
const onSwipeEnd = () => {
setTimeout(() => {
destroyToast(id.value);
}, 1000);
};
// computed
const statusIcon = computed(() => {
const status = options.value.status;
switch (status) {
case "success":
return {
name: "duo-icons:check-circle",
class: "**:fill-success-500 [filter:drop-shadow(0_0_10px_var(--color-success-500))]"
};
case "error":
return {
name: "duo-icons:alert-triangle",
class: "**:fill-danger-500 [filter:drop-shadow(0_0_10px_var(--color-danger-500))]"
};
case "info":
return {
name: "duo-icons:info",
class: "**:fill-cyan-500 [filter:drop-shadow(0_0_10px_var(--color-cyan-500))]"
};
case "warning":
return {
name: "duo-icons:alert-octagon",
class: "**:fill-warning-500 [filter:drop-shadow(0_0_10px_var(--color-warning-500))]"
};
default:
return {
name: "duo-icons:info",
class: "**:fill-slate-500 [filter:drop-shadow(0_0_10px_var(--color-slate-500))]"
};
}
});
// watch
watch(() => open.value, (value) => {
if (!value) onSwipeEnd();
});
// lifecycle
onMounted(() => {
setTimeout(() => {
open.value = false;
}, options.value.duration ?? 4000);
});
</script>
<template>
<ToastRoot
:duration="options.duration ?? 4000"
@swipeEnd="onSwipeEnd"
v-model:open="open"
class="bg-white shadow-md shadow-black/3 border-t-[0.5px] border-slate-200 border-black p-4 grid [grid-template-areas:_'title_action'_'description_action'] grid-cols-[auto_max-content] gap-x-[15px] items-center data-[state=open]:animate-toast-in data-[state=closed]:animate-toast-hide data-[swipe=move]:translate-x-[var(--reka-toast-swipe-move-x)] data-[swipe=cancel]:translate-x-0 data-[swipe=cancel]:transition-[transform_200ms_ease-out] data-[swipe=end]:animate-toast-out"
:class="options.description ? 'rounded-150' : 'rounded-full'"
>
<ToastTitle
:class="[ { 'mb-1.5' : options.description } ]"
class="[grid-area:_title] font-medium text-slate-600 text-sm flex items-center gap-2"
>
<Icon :name="statusIcon.name" :class="statusIcon.class" size="24" />
<span>{{ message }}</span>
</ToastTitle>
<ToastDescription v-if="options.description" as-child>
<div
class="[grid-area:_description] m-0 mr-8 text-slate-500 typo-p-sm text-justify"
>
{{ options.description }}
</div>
</ToastDescription>
</ToastRoot>
</template>
@@ -0,0 +1,19 @@
<script setup>
// state
import ToastBox from "~/components/ui/ToastContainer/ToastBox.vue";
const { toasts } = useToast();
</script>
<template>
<ToastBox
v-for="toast in toasts"
:key="toast.id"
:id="toast.id"
:message="toast.message"
:options="toast.options"
/>
</template>
+28
View File
@@ -0,0 +1,28 @@
// imports
import { useMutation } from "@tanstack/vue-query";
import axios from "~/configs/axios.config";
import { API_ENDPOINTS } from "~/constants";
// types
export type OtpRequest = {
phone: string;
};
// methods
export const handleOtp = async (variables: OtpRequest) => {
const { data } = await axios.post(`${API_ENDPOINTS.account.send_otp}`, variables);
return data;
};
// composable
const useOtp = () => {
return useMutation({
mutationFn: (variables: OtpRequest) => handleOtp(variables)
});
};
export default useOtp;
@@ -0,0 +1,29 @@
// imports
import { useMutation } from "@tanstack/vue-query";
import axios from "~/configs/axios.config";
import { API_ENDPOINTS } from "~/constants";
// types
export type SignInRequest = {
otp: string;
phone: string;
};
// methods
export const handleSignIn = async (variables: SignInRequest) => {
const { data } = await axios.post(`${API_ENDPOINTS.auth.signin}/`, variables);
return data;
};
// composable
const useSignIn = () => {
return useMutation({
mutationFn: (variables: SignInRequest) => handleSignIn(variables)
});
};
export default useSignIn;
@@ -0,0 +1,33 @@
// imports
import { useMutation } from "@tanstack/vue-query";
import axios from "~/configs/axios.config";
import { API_ENDPOINTS } from "~/constants";
// types
export type CreateBranchRequest = {
name: string;
description: string;
};
// methods
export const handleCreateBranch = async ({ name, description }: CreateBranchRequest) => {
const payload: CreateBranchRequest = {
name,
description,
};
await axios.post<CreateBranchRequest>(API_ENDPOINTS.branch.createBranch, payload);
};
// composable
const useCreateBranch = () => {
return useMutation({
mutationFn: (data: CreateBranchRequest) => handleCreateBranch(data),
});
};
export default useCreateBranch;
@@ -0,0 +1,54 @@
// imports
import { useQuery } from "@tanstack/vue-query";
import axios from "~/configs/axios.config";
import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
import type { ComputedRef } from "vue";
// types
export type GetBranchResponse = Branch;
// methods
export const handleGetBranch = async (
branchId: string,
page: string | undefined,
folderId: string | undefined,
sort: string | undefined,
signal: AbortSignal
) => {
const { data } = await axios.get<GetBranchResponse>(`${API_ENDPOINTS.branch.get}/${branchId}`, {
signal,
params: {
sort_by: sort,
folder_id: folderId,
offset: ((!!page ? Number(page) : 1) * 20) - 20,
limit: 20
}
});
return data;
};
// composable
const useGetBranch = (
branchId: ComputedRef<string>,
page: ComputedRef<string | undefined>,
folderId: ComputedRef<string | undefined>,
sort: ComputedRef<string | undefined>
) => {
return useQuery({
queryKey: [QUERY_KEYS.branch, branchId, page, folderId, sort],
queryFn: ({ signal }) => handleGetBranch(
branchId.value,
page.value,
folderId.value,
sort.value,
signal
)
});
};
export default useGetBranch;
@@ -0,0 +1,29 @@
// imports
import { useQuery } from "@tanstack/vue-query";
import axios from "~/configs/axios.config";
import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
// types
export type GetBranchesResponse = Branch[];
// methods
export const handleGetBranches = async () => {
const { data } = await axios.get<GetBranchesResponse>(`${API_ENDPOINTS.branch.getAll}`);
return data;
};
// composable
const useGetBranches = () => {
return useQuery({
staleTime: 60 * 1000,
queryKey: [QUERY_KEYS.branches],
queryFn: () => handleGetBranches()
});
};
export default useGetBranches;
@@ -0,0 +1,31 @@
// imports
import { useQuery } from "@tanstack/vue-query";
import axios from "~/configs/axios.config";
import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
// types
export type GetUserBranchesResponse = Branch[];
// methods
export const handleGetUserBranches = async () => {
const { data } = await axios.get<GetUserBranchesResponse>(
`${API_ENDPOINTS.branch.getUserBranches}`
);
return data;
};
// composable
const useGetUserBranches = () => {
return useQuery({
staleTime: 60 * 1000,
queryKey: [QUERY_KEYS.userBranches],
queryFn: () => handleGetUserBranches(),
});
};
export default useGetUserBranches;
@@ -0,0 +1,116 @@
// imports
import { QueryClient, useMutation } from "@tanstack/vue-query";
import type { InfiniteData } from "@tanstack/vue-query";
import axios from "~/configs/axios.config";
import { API_ENDPOINTS, MUTATION_KEYS, QUERY_KEYS } from "~/constants";
// types
export type CreateChatMessageRequest = {
productId: string | number;
new_message: string;
};
export type CreateChatMessageResponse = Chat[]
// methods
export const handleCreateChatMessage = async (variables: CreateChatMessageRequest) => {
const { data } = await axios.post<CreateChatMessageResponse>(`${API_ENDPOINTS.chat.new_message}/${variables.productId}`, variables, {
headers: {
Authorization: `Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoyMTY3ODE2OTAwLCJpYXQiOjE3MzU4MTY5MDAsImp0aSI6ImQwN2E2Y2Y2NzgwZjRlNTE5NWIzOGQxMTAzYzU4NDQ3IiwidXNlcl9pZCI6NX0.slwd7ZSV7nUXEuDTYwwHUOo9ekCefwEEL4kVv2vSTFo`
}
});
return data;
};
// composable
const useCreateChatMessage = (queryClient: QueryClient) => {
return useMutation({
mutationKey: [MUTATION_KEYS.create_chat],
mutationFn: (variables: CreateChatMessageRequest) => handleCreateChatMessage(variables),
onMutate: (newMessage) => {
const prevData = queryClient.getQueriesData({ queryKey: [QUERY_KEYS.chat] });
queryClient.setQueryData<InfiniteData<ApiPaginated<Chat>>>([QUERY_KEYS.chat], (oldData) => {
const lastPage = oldData!.pages[oldData!.pages.length - 1];
return {
pages: [
{
count: lastPage.count,
next: lastPage.next,
previous: lastPage.previous,
results: [
{
id: Date.now(),
content: newMessage.new_message,
sender: "user"
}
]
},
...oldData!.pages
],
pageParams: [
...oldData!.pageParams,
{
limit: 10,
offset: 0
}
]
};
});
return { prevData: prevData ? prevData[0][1] : undefined };
},
onSuccess: (response) => {
queryClient.setQueryData<InfiniteData<ApiPaginated<Chat>>>([QUERY_KEYS.chat], (oldData) => {
if (oldData) {
const lastPage = oldData!.pages[oldData!.pages.length - 1];
return {
pages: [
{
count: lastPage.count,
next: lastPage.next,
previous: lastPage.previous,
results: {
...response[0],
id: Date.now()
}
},
...oldData!.pages
],
pageParams: [
...oldData!.pageParams,
{
limit: 10,
offset: 0
}
]
};
}
return oldData;
});
},
onError: (err, newMessage, context) => {
if (context) {
queryClient.setQueryData(
[QUERY_KEYS.chat],
context.prevData
);
}
},
onSettled: (newMessage) => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.chat] });
}
});
};
export default useCreateChatMessage;
@@ -0,0 +1,60 @@
// imports
import { useInfiniteQuery, useQuery } from "@tanstack/vue-query";
import axios from "~/configs/axios.config";
import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
import type { ComputedRef } from "vue";
// types
export type GetBranchResponse = ApiPaginated<Chat>;
// methods
export const handleGetChat = async ({ productId, limit, offset }: {
productId: number | string,
limit: number,
offset: number
}) => {
const { data } = await axios.get<GetBranchResponse>(`${API_ENDPOINTS.chat.messages}/${productId}`, {
params: {
offset,
limit
},
headers: {
Authorization: `Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoyMTY3ODE2OTAwLCJpYXQiOjE3MzU4MTY5MDAsImp0aSI6ImQwN2E2Y2Y2NzgwZjRlNTE5NWIzOGQxMTAzYzU4NDQ3IiwidXNlcl9pZCI6NX0.slwd7ZSV7nUXEuDTYwwHUOo9ekCefwEEL4kVv2vSTFo`
}
});
return data;
};
// composable
const useGetBranch = (
productId: Ref<string | number>,
enabled: Ref<boolean>
) => {
return useInfiniteQuery({
enabled,
queryKey: [QUERY_KEYS.chat],
initialPageParam: {
limit: 10,
offset: 0
},
queryFn: ({ pageParam }) => handleGetChat({
limit: pageParam.limit,
offset: pageParam.offset,
productId: productId.value
}),
getNextPageParam: (lastPage, pages) => {
if (!lastPage.next) return undefined;
return {
limit: 10,
offset: pages.length * 10
};
}
});
};
export default useGetBranch;
@@ -0,0 +1,45 @@
// imports
import { useMutation } from "@tanstack/vue-query";
import axios from "~/configs/axios.config";
import { API_ENDPOINTS } from "~/constants";
// types
export type AddDocRequest = {
name: string,
parent?: string,
branch: string | undefined,
type: {
title: "File" | "Folder",
icon: "bi:folder" | "bi:file-earmark"
},
content: File | undefined
};
// methods
export const handleAddDoc = async (variables: AddDocRequest & { updateUploadProgress: (p: number) => void }) => {
const { data } = await axios.post<AddDocRequest>(`${API_ENDPOINTS.branch.getDoc}/`, {
...variables,
type: variables.type.title.toLocaleLowerCase()
}, {
onUploadProgress: (progressEvent) => {
variables.updateUploadProgress(progressEvent.progress!);
},
headers: {
"Content-Type": "multipart/form-data"
}
});
return data;
};
// composable
const useAddDoc = () => {
return useMutation({
mutationFn: (variables: AddDocRequest & { updateUploadProgress: (p: number) => void }) => handleAddDoc(variables)
});
};
export default useAddDoc;
@@ -0,0 +1,27 @@
// imports
import { useMutation } from "@tanstack/vue-query";
import axios from "~/configs/axios.config";
import { API_ENDPOINTS } from "~/constants";
// types
export type DeleteDocRequest = {
id: number
};
// methods
export const handleDeleteDoc = async ({ id }: { id: string | undefined }) => {
await axios.delete<DeleteDocRequest>(`${API_ENDPOINTS.branch.getDoc}/${id}`);
};
// composable
const useDeleteDoc = (id: Ref<string | undefined>) => {
return useMutation({
mutationFn: () => handleDeleteDoc({ id: id.value })
});
};
export default useDeleteDoc;
@@ -0,0 +1,29 @@
// imports
import { useMutation } from "@tanstack/vue-query";
import axios from "~/configs/axios.config";
import { API_ENDPOINTS } from "~/constants";
// types
export type EditDocRequest = {
id: number
};
// methods
export const handleEditDoc = async ({ id, name }: { id: string | undefined, name: string | undefined }) => {
await axios.patch<EditDocRequest>(`${API_ENDPOINTS.branch.getDoc}/${id}`, { name });
};
// composable
const useEditDoc = (id: Ref<string | undefined>) => {
return useMutation({
mutationFn: ({ name }: { name: Ref<string | undefined> }) => {
return handleEditDoc({ id: id.value, name: name.value });
}
});
};
export default useEditDoc;
@@ -0,0 +1,33 @@
// imports
import { useQuery } from "@tanstack/vue-query";
import axios from "~/configs/axios.config";
import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
// types
export type GetDocResponse = DocumentStructure;
// methods
export const handleGetDoc = async (id : string | undefined) => {
const { data } = await axios.get<GetDocResponse>(`${API_ENDPOINTS.branch.getDoc}/${id}`);
return data;
};
// composable
const useGetDoc = (id: ComputedRef<string | undefined>) => {
const enabled = computed(() => {
return !!id.value
});
return useQuery({
enabled,
queryKey: [QUERY_KEYS.document, id],
queryFn: () => handleGetDoc(id.value)
});
};
export default useGetDoc;
@@ -0,0 +1,32 @@
// imports
import { useMutation } from "@tanstack/vue-query";
import axios from "~/configs/axios.config";
import { API_ENDPOINTS } from "~/constants";
// types
export type MoveDocRequest = {
itemsToMove: number[] | string[],
parent: number | string
};
// methods
export const handleMoveDoc = async ({ itemsToMove, parent }: MoveDocRequest) => {
const apiUrl = `${API_ENDPOINTS.branch.moveDoc}?new_parent_id=${parent}&${itemsToMove.map(i => `patch_list=${i}&`)}`
const splittedUrl = apiUrl.split("");
splittedUrl.pop()
await axios.patch<MoveDocRequest>(splittedUrl.join("").replaceAll(",", ""));
};
// composable
const useMoveDoc = () => {
return useMutation({
mutationFn: (variables: MoveDocRequest) => handleMoveDoc(variables)
});
};
export default useMoveDoc;
@@ -0,0 +1,40 @@
// imports
import { useMutation } from "@tanstack/vue-query";
import axios from "~/configs/axios.config";
import { API_ENDPOINTS } from "~/constants";
// types
export type ReplyDocRequest = {
user_id: number;
message: string;
reply_id: number;
};
export type ReplyDocResponse = {
chat_id: number;
};
// methods
export const handleReplyDoc = async ({ user_id, message, reply_id }: ReplyDocRequest) => {
const payload = {
user_id,
message,
item_id: reply_id,
};
const { data } = await axios.post<ReplyDocResponse>(API_ENDPOINTS.branch.replyDoc, payload);
return data;
};
// composable
const useReplyDoc = () => {
return useMutation({
mutationFn: (data: ReplyDocRequest) => handleReplyDoc(data),
});
};
export default useReplyDoc;
@@ -0,0 +1,78 @@
// imports
import { useInfiniteQuery } from "@tanstack/vue-query";
import axios from "~/configs/axios.config";
import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
import type { ComputedRef } from "vue";
// types
export type SearchFileResponse = Branch;
type HandlerProps = typeof initialPageParam & {
signal: AbortSignal,
search: string,
id: number | undefined;
sort: string | undefined;
}
// state
const initialPageParam = {
limit: 10,
offset: 0
};
// methods
export const handleSearchFile = async ({ search, offset, limit, id, signal, sort }: HandlerProps) => {
const { data } = await axios.get<SearchFileResponse>(`${API_ENDPOINTS.branch.get}/${id}`, {
params: {
offset,
limit,
search,
sort_by: sort
},
signal
});
return data;
};
// composable
const useSearchFile = (search: Ref<string>, id: Ref<number | undefined>, sort: ComputedRef<string | undefined>) => {
const enabled = computed(() => {
return search.value.trim() != "" && !!id.value;
});
return useInfiniteQuery({
enabled,
retry: false,
refetchOnMount: false,
refetchOnWindowFocus: false,
queryKey: [QUERY_KEYS.searchFile, search, id, sort],
queryFn: ({ pageParam, signal }) => handleSearchFile({
...pageParam,
signal,
search: search.value,
sort: sort.value,
id: id.value
}),
initialPageParam,
getNextPageParam: (lastPage, pages) => {
const page = pages.length + 1;
const limit = initialPageParam.limit;
const nextPageParams = {
offset: page * limit - limit,
limit
};
return lastPage?.structure.next ? nextPageParams : undefined;
}
});
};
export default useSearchFile;
+32
View File
@@ -0,0 +1,32 @@
import type { FastAverageColorResult } from "fast-average-color";
import { FastAverageColor } from "fast-average-color";
export const useImageColor = (img: string) => {
const fac = new FastAverageColor();
const colorObject = ref<FastAverageColorResult>();
const isPending = ref(false);
const extractImageColor = async () => {
isPending.value = true;
const imageEl = document.querySelector(img) as HTMLImageElement;
try {
const color = await fac.getColorAsync(imageEl);
isPending.value = false;
colorObject.value = color;
} catch (e) {
isPending.value = false;
}
};
onMounted(() => {
extractImageColor();
});
return {
colorObject,
extractImageColor,
isPending
};
};
+42
View File
@@ -0,0 +1,42 @@
type Props = {
duration: number;
callback?: () => void
}
export function useTimer({ duration, callback }: Props) {
const timeout = ref<NodeJS.Timeout | null>(null);
const interval = ref<NodeJS.Timeout | null>(null);
const isPending = ref(false);
const timer = ref(duration / 1000);
const reset = () => {
if (timeout.value) clearTimeout(timeout.value);
if (interval.value) clearInterval(interval.value);
isPending.value = false;
timer.value = duration / 1000;
};
const start = () => {
isPending.value = true;
timeout.value = setTimeout(() => {
if (interval.value) clearInterval(interval.value);
if (callback) callback();
isPending.value = false;
timer.value = duration / 1000;
}, duration);
interval.value = setInterval(() => {
timer.value -= 1;
}, 1000);
};
return {
isPending,
timer,
reset,
start
};
}
+31
View File
@@ -0,0 +1,31 @@
export type ToastOptions = {
description?: string;
duration?: number;
status?: "success" | "error" | "info" | "warning",
}
type Toast = {
id: number;
message: string;
options?: ToastOptions
}
const toasts = ref<Toast[]>([]);
export function useToast() {
const addToast = ({ message, options = {} }: Omit<Toast, "id">) => {
const id = Date.now();
toasts.value.push({ id, message, options });
};
const destroyToast = (id: number) => {
toasts.value = toasts.value.filter(toast => toast.id !== id);
};
return {
toasts,
addToast,
destroyToast
};
}
+27 -16
View File
@@ -1,30 +1,41 @@
export const API_ENDPOINTS = {
account: {
send_otp: "/accounts/send_otp"
},
auth: {
login: "/token",
logout: "/accounts/logout",
signin: "/token",
logout: "/accounts/logout"
},
chat: {
messages: "/chat/product",
new_message: "/chat/product"
}
};
export const QUERY_KEYS = {
chat: "chat",
};
export const MUTATION_KEYS = {
create_chat: "create_chat",
};
export const FILE_FORMATS = [
{
ext: [".jpg", ".jpeg", ".png", ".svg", ".bmp", ".webp", ".gif", ".tiff", ".heif", ".raw"],
icon: "bi:file-earmark-image",
icon: "bi:file-earmark-image"
},
{
ext: [".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".mpeg", ".3gp", ".mts"],
icon: "bi:file-earmark-play",
icon: "bi:file-earmark-play"
},
{
ext: [".zip", ".rar", ".tar.gz", ".dmg", ".7z", ".bz2", ".tar", ".gz", ".xz"],
icon: "bi:file-earmark-zip",
icon: "bi:file-earmark-zip"
},
{
ext: [".mp3", ".wav", ".aac", ".flac", ".ogg", ".wma", ".alac", ".m4a", ".opus", ".aiff"],
icon: "bi:file-earmark-music",
icon: "bi:file-earmark-music"
},
{
ext: [
@@ -42,9 +53,9 @@ export const FILE_FORMATS = [
".sh",
".md",
".rust",
".pl",
".pl"
],
icon: "bi:file-earmark-code",
icon: "bi:file-earmark-code"
},
{
ext: [
@@ -60,28 +71,28 @@ export const FILE_FORMATS = [
".tsv",
".ods",
".db",
".sqlite",
".sqlite"
],
icon: "bi:file-earmark-spreadsheet",
icon: "bi:file-earmark-spreadsheet"
},
{
ext: [".pdf", ".epub", ".xps", ".djvu", ".cbz", ".cbt"],
icon: "bi:file-earmark-pdf",
icon: "bi:file-earmark-pdf"
},
{
ext: [".ppt", ".pptx", ".odp", ".pps", ".ppsx", ".key"],
icon: "bi:file-earmark-slides",
icon: "bi:file-earmark-slides"
},
{
ext: [".txt", ".doc", ".docx", ".odt", ".rtf", ".wps", ".wpd"],
icon: "bi:file-earmark-text",
icon: "bi:file-earmark-text"
},
{
ext: [".lock", ".lck"],
icon: "bi:file-earmark-lock",
icon: "bi:file-earmark-lock"
},
{
ext: [".exe", ".bin", ".elf", ".dll", ".iso", ".dmg", ".swf", ".o", ".obg", ".pe", ".app"],
icon: "bi:file-earmark-binary",
},
icon: "bi:file-earmark-binary"
}
];
+6
View File
@@ -44,4 +44,10 @@ export default defineNuxtConfig({
"@nuxt/icon",
"reka-ui/nuxt",
],
runtimeConfig: {
public: {
API_BASE_URL: "http://38.60.202.91:8001",
},
},
});
+101 -11
View File
@@ -11,15 +11,17 @@
"@nuxtjs/google-fonts": "^3.2.0",
"@tanstack/vue-query": "^5.62.2",
"@tanstack/vue-query-devtools": "^5.62.3",
"@vuelidate/core": "^2.0.3",
"@vuelidate/validators": "^2.0.4",
"axios": "^1.7.9",
"fast-average-color": "^9.4.0",
"gsap": "^3.12.5",
"nuxt": "^3.14.1592",
"reka-ui": "^1.0.0-alpha.6",
"swiper": "^11.1.15",
"vue": "latest",
"vue-router": "latest",
"vue-scrollto": "^2.20.0",
"vue-toastification": "^2.0.0-rc.5"
"vue-scrollto": "^2.20.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0-beta.5",
@@ -3309,6 +3311,94 @@
"integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==",
"license": "MIT"
},
"node_modules/@vuelidate/core": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@vuelidate/core/-/core-2.0.3.tgz",
"integrity": "sha512-AN6l7KF7+mEfyWG0doT96z+47ljwPpZfi9/JrNMkOGLFv27XVZvKzRLXlmDPQjPl/wOB1GNnHuc54jlCLRNqGA==",
"license": "MIT",
"dependencies": {
"vue-demi": "^0.13.11"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^2.0.0 || >=3.0.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@vuelidate/core/node_modules/vue-demi": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz",
"integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@vuelidate/validators": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@vuelidate/validators/-/validators-2.0.4.tgz",
"integrity": "sha512-odTxtUZ2JpwwiQ10t0QWYJkkYrfd0SyFYhdHH44QQ1jDatlZgTh/KRzrWVmn/ib9Gq7H4hFD4e8ahoo5YlUlDw==",
"license": "MIT",
"dependencies": {
"vue-demi": "^0.13.11"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^2.0.0 || >=3.0.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@vuelidate/validators/node_modules/vue-demi": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz",
"integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@vueuse/core": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.0.0.tgz",
@@ -5073,6 +5163,15 @@
"ufo": "^1.1.2"
}
},
"node_modules/fast-average-color": {
"version": "9.4.0",
"resolved": "https://registry.npmjs.org/fast-average-color/-/fast-average-color-9.4.0.tgz",
"integrity": "sha512-bvM8vV6YwK07dPbzFz77zJaBcfF6ABVfgNwaxVgXc2G+o0e/tzLCF9WU8Ryp1r0Nkk6JuJNsWCzbb4cLOMlB+Q==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -10451,15 +10550,6 @@
"bezier-easing": "2.1.0"
}
},
"node_modules/vue-toastification": {
"version": "2.0.0-rc.5",
"resolved": "https://registry.npmjs.org/vue-toastification/-/vue-toastification-2.0.0-rc.5.tgz",
"integrity": "sha512-q73e5jy6gucEO/U+P48hqX+/qyXDozAGmaGgLFm5tXX4wJBcVsnGp4e/iJqlm9xzHETYOilUuwOUje2Qg1JdwA==",
"license": "MIT",
"peerDependencies": {
"vue": "^3.0.2"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+5 -2
View File
@@ -3,6 +3,7 @@
"private": true,
"type": "module",
"scripts": {
"start": "node .output/server/index.mjs",
"build": "nuxt build",
"dev": "nuxt dev",
"dev-o": "nuxt dev -- -o",
@@ -15,15 +16,17 @@
"@nuxtjs/google-fonts": "^3.2.0",
"@tanstack/vue-query": "^5.62.2",
"@tanstack/vue-query-devtools": "^5.62.3",
"@vuelidate/core": "^2.0.3",
"@vuelidate/validators": "^2.0.4",
"axios": "^1.7.9",
"fast-average-color": "^9.4.0",
"gsap": "^3.12.5",
"nuxt": "^3.14.1592",
"reka-ui": "^1.0.0-alpha.6",
"swiper": "^11.1.15",
"vue": "latest",
"vue-router": "latest",
"vue-scrollto": "^2.20.0",
"vue-toastification": "^2.0.0-rc.5"
"vue-scrollto": "^2.20.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0-beta.5",
+68
View File
@@ -0,0 +1,68 @@
<template>
<div class="container">
<div class="mt-20">
<span class="typo-h-3 text-black">دسته بندی ها</span>
</div>
<div class="grid grid-cols-3 gap-4 w-full mt-12">
<CategoryCard
:id="1"
category="یک دسته بندی تست"
picture="/img/product-1.jpg"
:count="20"
description="یک دسته بندی تستasdasd"
dark-layer
/>
<CategoryCard
:id="2"
category="یک دسته بندی تست"
picture="/img/product-2.jpg"
:count="20"
description="یک دسته بندی تستasdasd"
/>
<CategoryCard
:id="3"
category="یک دسته بندی تست"
picture="/img/product-3.jpg"
:count="20"
description="یک دسته بندی تستasdasd"
/>
<CategoryCard
:id="8"
category="یک دسته بندی تست"
picture="/img/product-4.jpg"
:count="20"
description="یک دسته بندی تستasdasd"
/>
<CategoryCard
:id="4"
category="یک دسته بندی تست"
picture="/img/product-5.jpg"
:count="20"
description="یک دسته بندی تستasdasd"
/>
<CategoryCard
:id="5"
category="یک دسته بندی تست"
picture="/img/product-1.jpg"
:count="20"
description="یک دسته بندی تستasdasd"
/>
<CategoryCard
:id="6"
category="یک دسته بندی تست"
picture="/img/product-2.jpg"
:count="20"
description="یک دسته بندی تستasdasd"
/>
<CategoryCard
:id="7"
category="یک دسته بندی تست"
picture="/img/product-3.jpg"
:count="20"
description="یک دسته بندی تستasdasd"
/>
</div>
</div>
</template>
<script setup lang="ts">
</script>
+4 -1
View File
@@ -1,4 +1,6 @@
<script lang="ts" setup></script>
<script lang="ts" setup>
import ChatButton from "~/components/product/ChatBox/ChatButton.vue";
</script>
<template>
<div class="w-full flex flex-col">
@@ -7,5 +9,6 @@
<ProductComments />
<ProductDetails />
<RelatedProducts title="محصولات مشابه" />
<ChatButton />
</div>
</template>
+206
View File
@@ -0,0 +1,206 @@
<script lang="ts" setup>
// import
import { helpers, required } from "@vuelidate/validators";
import { useVuelidate } from "@vuelidate/core";
import useOtp from "~/composables/api/auth/useOtp";
import { useTimer } from "~/composables/useTimer";
import useSignIn from "~/composables/api/auth/useSignIn";
// types
type LoginInfo = {
phone: string;
};
// state
const { addToast } = useToast();
const showOtp = ref(false);
const otpCode = ref([]);
const formRules = computed(() => {
return {
phone: {
required: helpers.withMessage("Phone is required", required),
phoneValidator: helpers.regex(/^[1-9][0-9]{9}$/)
}
};
});
const loginInfo = ref<LoginInfo>({
phone: ""
});
const formValidator$ = useVuelidate(formRules, loginInfo);
const {
timer: otpBlockerTimePassed,
start: startOtpBlocker,
reset: resetOtpBlocker,
isPending: isResendOtpBlocked
} = useTimer({
duration: 5000
});
const { mutateAsync: sendOtp, isPending: sendOtpIsPending } = useOtp();
const { mutateAsync: signIn, isPending: signInIsPending, status: signInStatus } = useSignIn();
// computed
const sendOtpHandler = async () => {
if (!sendOtpIsPending.value) {
try {
await sendOtp({
phone: `0${loginInfo.value.phone}`
});
addToast({
message: "کد برای شما ارسال شد",
options: {
status: "success"
}
});
showOtp.value = true;
} catch (e) {
addToast({
message: "مشکلی پیش آمده",
options: {
status: "error"
}
});
}
}
};
const handleLogin = async () => {
if (!showOtp.value) {
await formValidator$.value.$validate();
if (!formValidator$.value.$errors.length) {
await sendOtpHandler();
}
} else {
try {
await signIn({
otp: otpCode.value.join(""),
phone: `0${loginInfo.value.phone}`
});
} catch (e) {
otpCode.value = [];
addToast({ message: "مشکلی پیش آمده" });
}
}
};
const resendOtp = async () => {
try {
await sendOtpHandler();
resetOtpBlocker();
startOtpBlocker();
} catch (e) {
resetOtpBlocker();
}
};
const resetForm = () => {
loginInfo.value.phone = "";
formValidator$.value.$reset();
otpCode.value = [];
showOtp.value = false;
};
</script>
<template>
<div class="container min-h-[700px] flex flex-col items-center justify-center">
<h1 class="typo-hero-2">
فرم ورود
</h1>
<form
@submit.prevent
class="max-w-[500px] w-full mt-12"
>
<div
v-if="!showOtp"
class="flex items-center gap-2 w-full"
>
<Input
class="w-full"
v-model="loginInfo.phone"
placeholder="9380123456"
dir="ltr"
:error="formValidator$.phone.$error"
>
<template #startItem>
<span class="text-slate-500">
+98
</span>
</template>
</Input>
<div class="flex items-center gap-1">
<Icon class="translate-y-[-1px]" name="twemoji:flag-iran" size="24" />
</div>
</div>
<OtpInput
v-else
v-model="otpCode"
:status="signInStatus === 'success' ? 'success' : signInStatus === 'error' ? 'error' : 'idle'"
:disabled="signInIsPending || sendOtpIsPending"
:autofocus="true"
@complete="handleLogin"
/>
<Button
v-if="!showOtp"
class="rounded-full w-full mt-4"
type="submit"
@click="handleLogin"
:loading="sendOtpIsPending"
:disabled="sendOtpIsPending"
>
ارسال کد
</Button>
<div
v-else
class="flex items-center w-full gap-4 mt-4"
>
<Button
class="rounded-full w-full mt-4"
type="button"
variant="secondary"
@click="resetForm"
:disabled="signInIsPending || sendOtpIsPending"
>
تغییر شماره
</Button>
<Button
class="rounded-full w-full mt-4"
type="submit"
@click="resendOtp"
:loading="signInIsPending || sendOtpIsPending"
:disabled="signInIsPending || isResendOtpBlocked || sendOtpIsPending"
>
ارسال مجدد کد
{{ isResendOtpBlocked ? otpBlockerTimePassed : "" }}
</Button>
</div>
<div class="flex items-center gap-2 justify-center mt-6">
<span>
بازگشت به فروشگاه
</span>
<Icon
name="ci:left-rotation"
size="24"
/>
</div>
</form>
</div>
</template>
+2 -1
View File
@@ -1,10 +1,11 @@
import { gsap } from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { ScrollToPlugin } from 'gsap/ScrollToPlugin'
import { TextPlugin } from "gsap/TextPlugin";
export default defineNuxtPlugin(() => {
if (process.client) {
gsap.registerPlugin(ScrollTrigger, ScrollToPlugin)
gsap.registerPlugin(ScrollTrigger, ScrollToPlugin, TextPlugin)
}
return {
-18
View File
@@ -1,18 +0,0 @@
import Toast, { useToast } from "vue-toastification";
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.use(Toast, {
position: "top-center",
hideProgressBar: true,
transition: "Vue-Toastification__fade",
maxToasts: 3,
closeButton: false,
timeout: 1800,
});
return {
provide: {
toast: useToast(),
},
};
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 648 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

+7
View File
@@ -7,4 +7,11 @@ declare global {
previous: string | null;
results: T[];
};
type Chat = {
id: number,
sender: "ai" | "user",
content: string
}
}