Merge remote-tracking branch 'origin/main'

This commit is contained in:
marzban-dev
2025-02-26 21:01:57 +03:30
64 changed files with 1766 additions and 547 deletions
+2
View File
@@ -29,3 +29,5 @@ REFRESH_TOKEN_LIFETIME = 5000
SMS_API_KEY = '' SMS_API_KEY = ''
VAPID_PRIVATE_KEY = 'NajogmGTsGsZ_dfURrjUpgsm5fui-s5AzruBQgMh_I4' VAPID_PRIVATE_KEY = 'NajogmGTsGsZ_dfURrjUpgsm5fui-s5AzruBQgMh_I4'
OPENAI_API_KEY = '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'
@@ -0,0 +1,18 @@
# Generated by Django 5.1.2 on 2025-02-22 16:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0023_alter_securitybreachattemptmodel_city_and_more'),
]
operations = [
migrations.AlterField(
model_name='user',
name='birth_date',
field=models.DateField(blank=True, null=True),
),
]
+1 -1
View File
@@ -47,7 +47,7 @@ class User(AbstractBaseUser, PermissionsMixin):
('زن', 'زن') ('زن', 'زن')
) )
gender = models.CharField(choices=gender_option, max_length=20, verbose_name='جنسیت') gender = models.CharField(choices=gender_option, max_length=20, verbose_name='جنسیت')
birth_date = models.DateField() birth_date = models.DateField(blank=True, null=True)
date_joined = models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ثبتنام') date_joined = models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ثبتنام')
otp_hash = models.CharField(max_length=64, null=True, blank=True, verbose_name='کد یک بار مصرف') otp_hash = models.CharField(max_length=64, null=True, blank=True, verbose_name='کد یک بار مصرف')
otp_expiry = models.DateTimeField(null=True, blank=True, verbose_name='تاریخ تمام شدن کد یک بار مصرف') otp_expiry = models.DateTimeField(null=True, blank=True, verbose_name='تاریخ تمام شدن کد یک بار مصرف')
+6 -8
View File
@@ -39,10 +39,10 @@ class SendOTPView(APIView):
try: try:
user, created = User.objects.get_or_create(phone=phone) user, created = User.objects.get_or_create(phone=phone)
otp = user.set_otp() otp = user.set_otp()
message = f"کد یک بار مصرف : {otp}" message = f"""به فروشگاه هی ملز خوش اومدی!!❤️🤖
کد یک بار مصرف شما :
Code: {otp}"""
sms_api = ghasedak_sms.Ghasedak(api_key="4dc844abd4409fe247ec73831aed2498ad3749c1945660cc252654371756b966vafe5d9LGgMbnfGn") sms_api = ghasedak_sms.Ghasedak(api_key="1227eaaddcba72bcb0169b37032cf16ae9ac6ed8b3b7c2768b74e2ee351d1b52gyRe3AGomZRPTNEd")
# response = sms_api.send_single_sms(ghasedak_sms.SendSingleSmsInput(message=message, receptor=phone, line_number='30005006006908', send_date='', client_reference_id='')) # response = sms_api.send_single_sms(ghasedak_sms.SendSingleSmsInput(message=message, receptor=phone, line_number='30005006006908', send_date='', client_reference_id=''))
# print(response) # print(response)
@@ -53,8 +53,7 @@ class SendOTPView(APIView):
ghasedak_sms.SendSingleSmsInput( ghasedak_sms.SendSingleSmsInput(
message=message, message=message,
receptor=phone, receptor=phone,
line_number='50001212124889', line_number='30005006004095',
send_date='',
client_reference_id=str(user.pk) client_reference_id=str(user.pk)
) )
) )
@@ -64,9 +63,8 @@ class SendOTPView(APIView):
if response['statusCode'] == 200: if response['statusCode'] == 200:
return Response({'detail': 'OTP sent successfully'}, status=status.HTTP_200_OK) return Response({'detail': 'OTP sent successfully'}, status=status.HTTP_200_OK)
else: else:
print('remmber to remove #TODO')
print(response) print(response)
return Response({'detail': f'OTP sent successfully {otp}'}, status=status.HTTP_200_OK) return Response({'detail': f'مشکلی در ارسال کد رخ داد'}, status=status.HTTP_200_OK)
# return Response({'detail': response, 'otp_code': otp}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) # return Response({'detail': response, 'otp_code': otp}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
except User.DoesNotExist: except User.DoesNotExist:
+178 -147
View File
@@ -1,5 +1,4 @@
from dotenv import load_dotenv from dotenv import load_dotenv
# from http.cookiejar import debug
from pathlib import Path from pathlib import Path
from datetime import timedelta from datetime import timedelta
import os import os
@@ -7,15 +6,21 @@ from django.templatetags.static import static
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
# Load environment variables
load_dotenv(".env.local") load_dotenv(".env.local")
# ==============================================================================
# General Configuration
# ==============================================================================
DOMAIN = os.getenv("DOMAIN") DOMAIN = os.getenv("DOMAIN")
API_DOMAIN = os.getenv("API_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")
# TODO update email bullshit # API Keys and Tokens
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
VAPID_PRIVATE_KEY = os.getenv("VAPID_PRIVATE_KEY")
# Email Configuration
EMAIL_BACKEND = os.getenv("EMAIL_BACKEND") EMAIL_BACKEND = os.getenv("EMAIL_BACKEND")
EMAIL_HOST = os.getenv("EMAIL_HOST") EMAIL_HOST = os.getenv("EMAIL_HOST")
EMAIL_PORT = 587 EMAIL_PORT = 587
@@ -24,17 +29,16 @@ EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER")
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD") EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD")
DEFAULT_FROM_EMAIL = os.getenv("SECRET_KEY") DEFAULT_FROM_EMAIL = os.getenv("SECRET_KEY")
# Security and Debugging
SECRET_KEY = os.getenv("SECRET_KEY") SECRET_KEY = os.getenv("SECRET_KEY")
DEBUG = True DEBUG = True
BASE_DIR = Path(__file__).resolve().parent.parent.parent BASE_DIR = Path(__file__).resolve().parent.parent.parent
# ==============================================================================
VAPID_PRIVATE_KEY = os.getenv('VAPID_PRIVATE_KEY') # Application Definition
# ==============================================================================
# Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
# unfold theme # Unfold Theme
"unfold", "unfold",
"unfold.contrib.filters", "unfold.contrib.filters",
"unfold.contrib.forms", "unfold.contrib.forms",
@@ -42,37 +46,159 @@ INSTALLED_APPS = [
"unfold.contrib.import_export", "unfold.contrib.import_export",
"unfold.contrib.guardian", "unfold.contrib.guardian",
"unfold.contrib.simple_history", "unfold.contrib.simple_history",
# django # Django Core
'django.contrib.admin', "django.contrib.admin",
'django.contrib.auth', "django.contrib.auth",
'django.contrib.contenttypes', "django.contrib.contenttypes",
'django.contrib.sessions', "django.contrib.sessions",
'django.contrib.messages', "django.contrib.messages",
'django.contrib.staticfiles', "django.contrib.staticfiles",
# thired party apps # Third-Party Apps
'corsheaders', "storages",
'rest_framework', "corsheaders",
'drf_spectacular', "rest_framework",
'django_cleanup.apps.CleanupConfig', "drf_spectacular",
'django_filters', "django_cleanup.apps.CleanupConfig",
'rest_framework_simplejwt', "django_filters",
'rest_framework_simplejwt.token_blacklist', "rest_framework_simplejwt",
'rest_framework.authtoken', "rest_framework_simplejwt.token_blacklist",
'import_export', "rest_framework.authtoken",
"import_export",
"django_jalali", "django_jalali",
# custom apps # Custom Apps
'product', "product",
'account', "account",
'ticket', "ticket",
'chat', "chat",
'order', "order",
'home', "home",
'blog', "blog",
] ]
# ==============================================================================
# Middleware Configuration
# ==============================================================================
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "core.urls"
# ==============================================================================
# Template Configuration
# ==============================================================================
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "core.wsgi.application"
# ==============================================================================
# Authentication and Password Validation
# ==============================================================================
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
LANGUAGE_CODE = "fa"
TIME_ZONE = "UTC"
USE_I18N = True
USE_L10N = True
USE_TZ = True
# ==============================================================================
# Static Files Configuration
# ==============================================================================
STATICFILES_DIRS = [
os.path.join(BASE_DIR, "custom_static"),
BASE_DIR / "core" / "static",
]
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
AUTH_USER_MODEL = "account.User"
# ==============================================================================
# REST Framework Configuration
# ==============================================================================
REST_FRAMEWORK = {
# "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
"DEFAULT_RENDERER_CLASSES": [
"rest_framework.renderers.JSONRenderer",
"rest_framework.renderers.BrowsableAPIRenderer",
],
"DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework_simplejwt.authentication.JWTAuthentication",
),
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
# "DEFAULT_PERMISSION_CLASSES": [
# "rest_framework.permissions.IsAuthenticated",
# ],
}
# ==============================================================================
# Simple JWT Configuration
# ==============================================================================
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(days=1),
"REFRESH_TOKEN_LIFETIME": timedelta(days=7),
"ROTATE_REFRESH_TOKENS": True,
"BLACKLIST_AFTER_ROTATION": True,
}
# ==============================================================================
# Spectacular (API Documentation) Configuration
# ==============================================================================
SPECTACULAR_SETTINGS = {
"TITLE": os.getenv("SITE_TITLE"),
"DESCRIPTION": os.getenv("SITE_TITLE"),
"VERSION": "2.0.0",
"SERVE_INCLUDE_SCHEMA": False,
"COMPONENT_SPLIT_REQUEST": True,
"SWAGGER_UI_SETTINGS": {
"persistAuthorization": True,
},
}
# ==============================================================================
# Persian Datetime Configuration
# ==============================================================================
JALALI_SETTINGS = { JALALI_SETTINGS = {
# JavaScript static files for the admin Jalali date widget
"ADMIN_JS_STATIC_FILES": [ "ADMIN_JS_STATIC_FILES": [
"admin/jquery.ui.datepicker.jalali/scripts/jquery-1.10.2.min.js", "admin/jquery.ui.datepicker.jalali/scripts/jquery-1.10.2.min.js",
"admin/jquery.ui.datepicker.jalali/scripts/jquery.ui.core.js", "admin/jquery.ui.datepicker.jalali/scripts/jquery.ui.core.js",
@@ -81,7 +207,6 @@ JALALI_SETTINGS = {
"admin/jquery.ui.datepicker.jalali/scripts/jquery.ui.datepicker-cc-fa.js", "admin/jquery.ui.datepicker.jalali/scripts/jquery.ui.datepicker-cc-fa.js",
"admin/main.js", "admin/main.js",
], ],
# CSS static files for the admin Jalali date widget
"ADMIN_CSS_STATIC_FILES": { "ADMIN_CSS_STATIC_FILES": {
"all": [ "all": [
"admin/jquery.ui.datepicker.jalali/themes/base/jquery-ui.min.css", "admin/jquery.ui.datepicker.jalali/themes/base/jquery-ui.min.css",
@@ -91,113 +216,19 @@ JALALI_SETTINGS = {
} }
MIDDLEWARE = [ # ==============================================================================
'django.middleware.security.SecurityMiddleware', # AWS S3 setting for production
"whitenoise.middleware.WhiteNoiseMiddleware", # ==============================================================================
'django.contrib.sessions.middleware.SessionMiddleware', AWS_ACCESS_KEY_ID = 'mtiSN2JWjWgyfr2u'
'corsheaders.middleware.CorsMiddleware', AWS_SECRET_ACCESS_KEY = 'ZGmOM6ekLJEswJS1kOLp49B8DQ3GT0HZ'
'django.middleware.common.CommonMiddleware', AWS_STORAGE_BUCKET_NAME = 'c262408'
'django.middleware.csrf.CsrfViewMiddleware', AWS_S3_ENDPOINT_URL = 'https://parspack.net'
'django.contrib.auth.middleware.AuthenticationMiddleware', AWS_S3_REGION_NAME = 'default'
'django.contrib.messages.middleware.MessageMiddleware', AWS_S3_SIGNATURE_VERSION = 's3'
'django.middleware.clickjacking.XFrameOptionsMiddleware', AWS_S3_ADDRESSING_STYLE = 'virtual'
] AWS_QUERYSTRING_AUTH = True
AWS_DEFAULT_ACL = None
ROOT_URLCONF = 'core.urls' AWS_S3_OBJECT_PARAMETERS = {
'CacheControl': 'max-age=86400',
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / "templates"],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'core.wsgi.application'
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
STATICFILES_DIRS = [
os.path.join(BASE_DIR, 'custom_static'),
BASE_DIR / "core" / "static"
]
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
AUTH_USER_MODEL = 'account.User'
REST_FRAMEWORK = {
# 'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'],
'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer',
'rest_framework.renderers.BrowsableAPIRenderer',
],
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
),
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
# 'DEFAULT_PERMISSION_CLASSES': [
# 'rest_framework.permissions.IsAuthenticated',
# ],
} }
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(days=1),
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
'ROTATE_REFRESH_TOKENS': True,
'BLACKLIST_AFTER_ROTATION': True,
}
SPECTACULAR_SETTINGS = {
'TITLE': os.getenv("SITE_TITLE"),
'DESCRIPTION': os.getenv("SITE_TITLE"),
'VERSION': '2.0.0',
'SERVE_INCLUDE_SCHEMA': False,
'COMPONENT_SPLIT_REQUEST': True
}
def environment_callback(request):
return ["نسخه ی توسعه", "success"]
+12 -1
View File
@@ -1,5 +1,7 @@
from .base import * from .base import *
from .unfold_conf import * from .unfold_conf import *
ALLOWED_HOSTS = ['127.0.0.1', 'localhost', DOMAIN, API_DOMAIN] ALLOWED_HOSTS = ['127.0.0.1', 'localhost', DOMAIN, API_DOMAIN]
CSRF_TRUSTED_ORIGINS = [ CSRF_TRUSTED_ORIGINS = [
f"https://{DOMAIN}", f"https://{DOMAIN}",
@@ -31,7 +33,16 @@ DATABASES = {
} }
} }
MEDIA_URL = '/shop_media/' STORAGES = {
"default": {
"BACKEND": 'core.storages.MediaStorage',
},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
}
MEDIA_URL = 'https://c262408.parspack.net/'
MEDIA_ROOT = '/app/media' MEDIA_ROOT = '/app/media'
STATIC_URL = '/shop_static/' STATIC_URL = '/shop_static/'
+7 -2
View File
@@ -28,7 +28,9 @@ UNFOLD = {
"LOGIN": { "LOGIN": {
"image": lambda request: static("favicon.png"), "image": lambda request: static("favicon.png"),
}, },
"STYLES": [
lambda request: static("rtl.css"),
],
"BORDER_RADIUS": "20px", "BORDER_RADIUS": "20px",
"SHOW_HISTORY": True, "SHOW_HISTORY": True,
@@ -80,7 +82,7 @@ UNFOLD = {
}, },
"SIDEBAR": { "SIDEBAR": {
"show_search": True, "show_search": False,
"show_all_applications": True, "show_all_applications": True,
"navigation": [ "navigation": [
{ {
@@ -263,3 +265,6 @@ UNFOLD = {
], ],
}, },
} }
def environment_callback(request):
return ["نسخه ی توسعه", "success"]
+310
View File
@@ -0,0 +1,310 @@
/* Base RTL adjustments */
[dir="rtl"] body {
direction: rtl;
text-align: right;
}
/* Headers and titles */
[dir="rtl"] .header,
[dir="rtl"] h1,
[dir="rtl"] h2,
[dir="rtl"] h3,
[dir="rtl"] .branding h1 {
text-align: right;
}
/* Float adjustments */
[dir="rtl"] .float-left {
float: right !important;
}
[dir="rtl"] .float-right {
float: left !important;
}
/* Margins and paddings */
[dir="rtl"] .margin-left-10 {
margin-right: 10px !important;
margin-left: 0 !important;
}
[dir="rtl"] .margin-right-10 {
margin-left: 10px !important;
margin-right: 0 !important;
}
[dir="rtl"] .padding-left-15 {
padding-right: 15px !important;
padding-left: 0 !important;
}
[dir="rtl"] .padding-right-15 {
padding-left: 15px !important;
padding-right: 0 !important;
}
/* Form elements */
[dir="rtl"] .aligned label {
padding: 0 0 3px 1em;
float: right !important;
text-align: right;
}
[dir="rtl"] .form-row {
direction: rtl;
}
[dir="rtl"] .form-row .field-box {
float: right;
margin-right: 0;
margin-left: 10px;
}
[dir="rtl"] input,
[dir="rtl"] select,
[dir="rtl"] textarea {
direction: rtl;
}
/* Buttons and submit row */
[dir="rtl"] .submit-row {
text-align: left;
}
[dir="rtl"] .submit-row input,
[dir="rtl"] .button {
margin-left: 0;
margin-right: 10px;
}
/* Inline groups (e.g., tabular or stacked inlines) */
[dir="rtl"] .inline-group {
direction: rtl;
}
[dir="rtl"] .inline-related h3 {
text-align: right;
}
[dir="rtl"] .inline-related .inline_label {
float: right;
padding-right: 0;
padding-left: 10px;
}
/* Tables */
[dir="rtl"] table {
direction: rtl;
}
[dir="rtl"] th,
[dir="rtl"] td {
text-align: right;
}
[dir="rtl"] .sortoptions {
float: left;
}
/* Navigation and sidebar (Unfold-specific) */
[dir="rtl"] .unfold-sidebar {
right: unset;
left: 0;
}
[dir="rtl"] .unfold-main {
margin-left: 0;
margin-right: 260px; /* Adjust based on sidebar width */
}
[dir="rtl"] .unfold-nav {
direction: rtl;
text-align: right;
}
[dir="rtl"] .unfold-nav li {
text-align: right;
}
[dir="rtl"] .unfold-nav .dropdown-menu {
right: unset;
left: 0;
}
/* Breadcrumbs */
[dir="rtl"] .breadcrumbs {
direction: rtl;
text-align: right;
}
[dir="rtl"] .breadcrumbs a {
margin-right: 0;
margin-left: 5px;
}
/* Filters (right sidebar) */
[dir="rtl"] #changelist-filter {
float: left;
text-align: right;
}
[dir="rtl"] #changelist-filter h3 {
text-align: right;
}
[dir="rtl"] #changelist-filter li {
padding-right: 0;
padding-left: 10px;
}
/* Miscellaneous */
[dir="rtl"] .object-tools {
float: left;
}
[dir="rtl"] .paginator {
direction: rtl;
text-align: left;
}
/* Enhanced RTL adjustments for Unfold navigation links (specific to navbar) */
[dir="rtl"] .unfold-nav a {
direction: rtl;
justify-content: flex-end; /* Align flex items to the right */
}
[dir="rtl"] .unfold-nav .flex {
flex-direction: row-reverse !important; /* Reverse the order of flex items (icon, text, badge) for RTL */
}
[dir="rtl"] .unfold-nav .material-symbols-outlined {
margin-right: 0 !important; /* Remove default right margin */
margin-left: 0.75rem !important; /* Equivalent to Tailwinds mr-3 in RTL (12px or 0.75rem) */
order: 2 !important; /* Place icon after text in flex direction */
}
[dir="rtl"] .unfold-nav .text-sm {
margin-right: 0.5rem !important; /* Space between text and badge (equivalent to Tailwind ml-2 in RTL) */
text-align: right;
direction: rtl;
order: 1 !important; /* Place text before badge in flex direction */
}
[dir="rtl"] .unfold-nav .bg-red-600 {
margin-left: 0 !important; /* Remove default left margin */
margin-right: 0 !important; /* No margin needed on right unless spacing is required */
order: 0 !important; /* Place badge first in flex direction (on the right in RTL) */
}
/* Ensure text alignment and direction for Persian */
[dir="rtl"] .unfold-nav .text-sm {
text-align: right;
direction: rtl;
}
/* RTL adjustments for navbar headers and expandable sections (including arrow icon) */
[dir="rtl"] .unfold-nav h2 {
direction: rtl;
text-align: right;
}
[dir="rtl"] .unfold-nav .flex-row {
flex-direction: row-reverse !important; /* Reverse flex direction for headers */
}
[dir="rtl"] .unfold-nav .material-symbols-outlined.ml-auto {
margin-left: 0 !important; /* Remove default left margin (Tailwind ml-auto) */
margin-right: auto !important; /* Push to the right in RTL */
transform: rotate(180deg) !important; /* Flip chevron_right for RTL (pointing left) */
order: 999 !important; /* Ensure its the last item in the flex order, on the right */
}
/* RTL adjustments for search bar and other navbar elements */
[dir="rtl"] #nav-filter {
direction: rtl;
text-align: right;
}
[dir="rtl"] .unfold-nav input[type="search"] {
padding-right: 0.75rem !important; /* Adjust padding for RTL */
padding-left: 2rem !important; /* Space for the search icon on the left */
}
[dir="rtl"] .unfold-nav .material-symbols-outlined.pl-3 {
padding-left: 0 !important; /* Remove padding-left */
padding-right: 0.75rem !important; /* Add padding-right for RTL */
}
[dir="rtl"] .mr-3 {
margin-left: .75rem !important;
margin-right: 0 !important;
}
/* badge fix */
[dir="rtl"] .bg-red-600 {
margin-left: 0rem !important;
margin-right: .5rem !important;
}
/* colapse fix */
/* [dir="rtl"] .ml-auto {
margin-left: 0rem !important;
margin-right: 8rem !important;
} */
[dir="rtl"] .absolute.bottom-0.left-0.rounded.top-0 {
left: auto;
right: 0;
}
/* log out fix */
[dir="rtl"] nav.absolute.bg-white.border.flex.flex-col.leading-none.py-1.-right-2.rounded.shadow-lg.top-7.w-52.z-50.dark\:bg-base-800.dark\:border-base-700 {
right: auto; /* Remove -right-2 effect */
left: 0; /* Anchor to right edge (left in RTL) */
}
/* filter sprator fix */
[dir="rtl"] ul.dark\:bg-base-900.border.border-base-200.flex.min-w-20.rounded.shadow-sm.text-font-default-light.dark\:border-base-700.dark\:text-font-default-dark.w-full li {
border-right: none;
border-left: 1px solid #404040; /* Matches border-base-200 */
}
[dir="rtl"] ul.dark\:bg-base-900.border.border-base-200.flex.min-w-20.rounded.shadow-sm.text-font-default-light.dark\:border-base-700.dark\:text-font-default-dark.w-full li:last-child {
border-left: 0;
}
/* Dark mode override */
[dir="rtl"] .dark ul.dark\:bg-base-900.border.border-base-200.flex.min-w-20.rounded.shadow-sm.text-font-default-light.dark\:border-base-700.dark\:text-font-default-dark.w-full li {
border-left: 1px solid #374151; /* Matches dark:border-base-700 */
}
/* import export sprator fix */
/* Desktop RTL: Swap right borders to left borders */
[dir="rtl"] ul.border.flex.flex-col.font-medium.mb-4.mt-2.rounded.shadow-sm.md\:flex-row.md\:mb-2.md\:mt-0.dark\:border-base-700.max-md\:w-full li.md\:border-r {
border-right: none; /* Remove md:border-r */
border-left: 1px solid #404040; /* Add left border, matching border-base-200 default */
}
[dir="rtl"] ul.border.flex.flex-col.font-medium.mb-4.mt-2.rounded.shadow-sm.md\:flex-row.md\:mb-2.md\:mt-0.dark\:border-base-700.max-md\:w-full li:last-child {
border-left: 0; /* No left border on last item */
}
/* Dark mode for desktop RTL */
[dir="rtl"] .dark ul.border.flex.flex-col.font-medium.mb-4.mt-2.rounded.shadow-sm.md\:flex-row.md\:mb-2.md\:mt-0.dark\:border-base-700.max-md\:w-full li.md\:border-r {
border-left: 1px solid #374151; /* Matches dark:border-base-700 */
}
[dir="rtl"] h2.font-semibold.flex.flex-row.group.items-center.mb-1.mx-3.py-1\.5.px-3.select-none.text-font-important-light.text-sm.dark\:text-font-important-dark.cursor-pointer.hover\:text-primary-600.dark\:hover\:text-primary-500 {
justify-content: space-between;
}
[dir="rtl"] span.material-symbols-outlined.ml-auto.text-base-400.transition-all.group-hover\:text-primary-600.dark\:group-hover\:text-primary-500 {
margin: 0 !important;
rotate: 90deg !important;
}
[dir="rtl"] div.overflow-hidden.relative.px-2.py-1.text-sm {
background: rgb(var(--color-primary-950));
border-radius: var(--border-radius, 6px);
padding: 8px 16px;
}
[dir="rtl"] div.flex.flex-row.relative.z-20 {
justify-content: space-between;
}
[dir="rtl"] strong.font-semibold.text-font-important-light.ml-auto.dark\:text-font-important-dark {
margin: 0 !important;
}
+6
View File
@@ -0,0 +1,6 @@
from storages.backends.s3boto3 import S3Boto3Storage
class MediaStorage(S3Boto3Storage):
location = 'media'
default_acl = 'private'
+2 -2
View File
@@ -16,8 +16,8 @@ urlpatterns = [
# path('auth/', include('djoser.urls.jwt')), # path('auth/', include('djoser.urls.jwt')),
path('home', HomeView.as_view()), path('home', HomeView.as_view()),
path('token/', CustomTokenObtainPairView.as_view(), name='token_obtain_pair'), 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/', FakeAdminLoginView.as_view()), # Fake admin path('admin/', FakeAdminLoginView.as_view()), # Fake admin
path('secret-admin/', admin.site.urls), # Real admin path('secret-admin/', admin.site.urls), # Real admin
path('schema/', SpectacularAPIView.as_view(), name='schema'), path('schema/', SpectacularAPIView.as_view(), name='schema'),
+11 -11
View File
@@ -51,10 +51,10 @@ def random_data():
], ],
"kpi": [ "kpi": [
{ {
"title": "IPhone 16 Pro Max", "title": "گوشی Iphone 16 pro",
"metric": f"${intcomma(f"{random.uniform(1000, 9999):.02f}")}", "metric": f"${intcomma(f"{random.uniform(1000, 9999):.02f}")}",
"footer": mark_safe( "footer": mark_safe(
f'<strong class="text-green-700 font-semibold dark:text-green-400">+{intcomma(f"{random.uniform(1, 9):.02f}")}%</strong>&nbsp;progress from last week' f'<strong class="text-green-700 font-semibold dark:text-green-400">+{intcomma(f"{random.uniform(1, 9):.02f}")}%</strong>&nbsp;درصد فروش کل'
), ),
"chart": json.dumps( "chart": json.dumps(
{ {
@@ -64,23 +64,23 @@ def random_data():
), ),
}, },
{ {
"title": "Macbook Pro M3", "title": "لپ تاپ Macbook Pro M3",
"metric": f"${intcomma(f"{random.uniform(1000, 9999):.02f}")}", "metric": f"${intcomma(f"{random.uniform(1000, 9999):.02f}")}",
"footer": mark_safe( "footer": mark_safe(
f'<strong class="text-green-700 font-semibold dark:text-green-400">+{intcomma(f"{random.uniform(1, 9):.02f}")}%</strong>&nbsp;progress from last week' f'<strong class="text-green-700 font-semibold dark:text-green-400">+{intcomma(f"{random.uniform(1, 9):.02f}")}%</strong>&nbsp;درصد فروش کل'
), ),
}, },
{ {
"title": "Apple Watch 8", "title": "ساعت هوشمند Apple Watch 8",
"metric": f"${intcomma(f"{random.uniform(1000, 9999):.02f}")}", "metric": f"${intcomma(f"{random.uniform(1000, 9999):.02f}")}",
"footer": mark_safe( "footer": mark_safe(
f'<strong class="text-green-700 font-semibold dark:text-green-400">+{intcomma(f"{random.uniform(1, 9):.02f}")}%</strong>&nbsp;progress from last week' f'<strong class="text-green-700 font-semibold dark:text-green-400">+{intcomma(f"{random.uniform(1, 9):.02f}")}%</strong>&nbsp;درصد فروش کل'
), ),
}, },
], ],
"progress": [ "progress": [
{ {
"title": "📱 Phone and Mobile", "title": "📱 موبایل و گوشی",
"description": "$2,499.99", "description": "$2,499.99",
"value": 20, "value": 20,
}, },
@@ -155,10 +155,10 @@ def random_data():
), ),
"performance": [ "performance": [
{ {
"title": _("Last week revenue"), "title": 'فروش ماه اخیر',
"metric": "$1,234.56", "metric": "$1,234.56",
"footer": mark_safe( "footer": mark_safe(
'<strong class="text-green-600 font-medium">+3.14%</strong>&nbsp;progress from last week' '<strong class="text-green-600 font-medium">+3.14%</strong>&nbsp;درصد فروش کل'
), ),
"chart": json.dumps( "chart": json.dumps(
{ {
@@ -173,10 +173,10 @@ def random_data():
), ),
}, },
{ {
"title": _("Last week expenses"), "title": 'مخارج ماه اخیر',
"metric": "$1,234.56", "metric": "$1,234.56",
"footer": mark_safe( "footer": mark_safe(
'<strong class="text-green-600 font-medium">+3.14%</strong>&nbsp;progress from last week' '<strong class="text-green-600 font-medium">+3.14%</strong>&nbsp;درصد فروش کل'
), ),
"chart": json.dumps( "chart": json.dumps(
{ {
+11 -9
View File
@@ -59,21 +59,23 @@ class OrderModel(models.Model):
print('didnt send') print('didnt send')
super().save(*args, **kwargs) super().save(*args, **kwargs)
def total_with_discount(self): def discount(self):
total_with_item_discount = sum(item.total_with_discount() for item in self.items.all()) pass
if self.discount_code: # total_with_item_discount = sum(item.total_with_discount() for item in self.items.all())
if not self.discount_code.is_valid(): # if self.discount_code:
raise DiscountNotAvailableError('این کد تخفیف دیگر معتبر نیست') # if not self.discount_code.is_valid():
discount_percent = self.discount_code.percent # raise DiscountNotAvailableError('این کد تخفیف دیگر معتبر نیست')
return total_with_item_discount * ((100 - discount_percent) / 100) # discount_percent = self.discount_code.percent
return total_with_item_discount # return total_with_item_discount * ((100 - discount_percent) / 100)
# return total_with_item_discount
def tax(self): def tax(self):
return self.total_without_tax() * 0.2 return self.total_without_tax() * 0.2
def total(self): def total(self):
return self.total_with_discount() + self.tax() pass
# return self.total_with_discount() + self.tax()
def remove_order_item(self, item_pk, quantity): def remove_order_item(self, item_pk, quantity):
pass pass
+15 -2
View File
@@ -8,7 +8,20 @@ class OrderItemSerailzier(serializers.ModelSerializer):
fields = "__all__" fields = "__all__"
read_only_fields = ('order', 'product') read_only_fields = ('order', 'product')
class OrderModelSerializer(serializers.ModelSerializer): class CartSerializer(serializers.ModelSerializer):
items = OrderItemSerailzier(many=True)
class Meta: class Meta:
model = OrderModel model = OrderModel
fields = ['address', 'created_at', 'is_paid', 'status', 'discount_code'] fields = ['address', 'created_at', 'is_paid', 'status', 'discount_code', 'items']
class OrderSerializer(serializers.ModelSerializer):
count = serializers.SerializerMethodField()
images = serializers.SerializerMethodField()
class Meta:
model = OrderModel
fields = ['address', 'created_at', 'is_paid', 'status', 'discount_code', "images", "count", "id"]
def get_count(self, obj):
return obj.items.all().count()
def get_images(self, obj):
return ["a" , "b" , "c"]
+4 -3
View File
@@ -1,11 +1,12 @@
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.urls import path, include from django.urls import path, include
from .views import CartItemViews, CartView from .views import CartItemViews, CartView, OrderlistView
urlpatterns = [ urlpatterns = [
path('list', OrderlistView.as_view(), name='order-list'),
path('cart', CartView.as_view()), path('cart', CartView.as_view()),
path('cart/item/<int:pk>', CartItemViews.as_view(), name='change-item-cart'), path('cart/item/<int:pk>', CartItemViews.as_view(), name='change-item-cart'),
path('payment', CartView.as_view()), # path('payment', CartView.as_view()),
path('', CartView.as_view()), # path('', CartView.as_view()),
] ]
+17 -7
View File
@@ -4,7 +4,7 @@ from rest_framework.views import APIView, Response
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from product.models import ProductVariant from product.models import ProductVariant
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from .serializers import OrderItemSerailzier from .serializers import *
# from cart.models import # from cart.models import
from rest_framework import status from rest_framework import status
from .models import OrderItemModel, OrderModel from .models import OrderItemModel, OrderModel
@@ -53,11 +53,6 @@ class CartItemViews(APIView):
user=request.user, user=request.user,
status='CART' status='CART'
) )
# order_item, created = OrderItemModel.objects.get_or_create(
# order=cart_order,
# product=product_variant,
# defaults={'quantity': request.data.get('quantity', 1)}
# )
order_item = get_object_or_404(OrderItemModel, order=cart_order, product=product_variant) order_item = get_object_or_404(OrderItemModel, order=cart_order, product=product_variant)
order_item.delete() order_item.delete()
return Response({'detail': f'محصول {product_variant.product.name} از سبد خرید پاک شد'}, status=status.HTTP_204_NO_CONTENT) return Response({'detail': f'محصول {product_variant.product.name} از سبد خرید پاک شد'}, status=status.HTTP_204_NO_CONTENT)
@@ -65,5 +60,20 @@ class CartItemViews(APIView):
class CartView(APIView): class CartView(APIView):
permission_classes = [IsAuthenticated]
serializer_class = CartSerializer
def get(self, request): def get(self, request):
return Response({'detail': 'این بخش در حال توسعه می باشد تا اماده شدن این بخش به نقاشی خود ادامه دهید'}, status=status.HTTP_404_NOT_FOUND) user = request.user
cart_instance, created = OrderModel.objects.get_or_create(user=user, status='CART')
cart_ser = self.serializer_class(instance=cart_instance, context={'request': request})
return Response(cart_ser.data, status=status.HTTP_200_OK)
class OrderlistView(APIView):
permission_classes = [IsAuthenticated]
serializer_class = OrderSerializer
def get(self, request):
user = request.user
orders = OrderModel.objects.filter(user=user).exclude(status="CART")
orders_ser = self.serializer_class(instance=orders, many=True, context={'request': request})
return Response(orders_ser.data, status=status.HTTP_200_OK)
+7 -7
View File
@@ -170,14 +170,14 @@ class AllProductsView(APIView):
if search_query: if search_query:
products = products.filter(Q(name__icontains=search_query) | Q(description__icontains=search_query)) products = products.filter(Q(name__icontains=search_query) | Q(description__icontains=search_query))
# Price filters # # Price filters
price_gte = request.query_params.get('price_gte', None) # price_gte = request.query_params.get('price_gte', None)
price_lte = request.query_params.get('price_lte', None) # price_lte = request.query_params.get('price_lte', None)
if price_gte: # if price_gte:
products = products.filter(variants__min_price__gte=price_gte) # products = products.filter(variants__min_price__gte=price_gte)
if price_lte: # if price_lte:
products = products.filter(variants__min_price__lte=price_lte) # products = products.filter(variants__min_price__lte=price_lte)
# Sorting # Sorting
sort_by = request.query_params.get('sort', None) sort_by = request.query_params.get('sort', None)
+32
View File
@@ -5,22 +5,33 @@ annotated-types==0.7.0
anyio==4.6.0 anyio==4.6.0
asgiref==3.8.1 asgiref==3.8.1
attrs==24.2.0 attrs==24.2.0
az-iranian-bank-gateways==2.0.5
beautifulsoup4==4.12.3
boto3==1.36.26
botocore==1.36.26
branca==0.8.1 branca==0.8.1
certifi==2024.8.30 certifi==2024.8.30
cffi==1.17.1 cffi==1.17.1
charset-normalizer==3.3.2 charset-normalizer==3.3.2
colorama==0.4.6
cryptography==44.0.1 cryptography==44.0.1
defusedxml==0.8.0rc2 defusedxml==0.8.0rc2
diff-match-patch==20230430 diff-match-patch==20230430
distro==1.9.0 distro==1.9.0
Django==5.1.2 Django==5.1.2
django-admin-interface==0.28.5
django-admin-persian-fonts==0.2
django-cleanup==8.1.0 django-cleanup==8.1.0
django-colorfield==0.11.0
django-cors-headers==4.4.0 django-cors-headers==4.4.0
django-cron==0.6.0
django-dbbackup==4.2.1 django-dbbackup==4.2.1
django-dirtyfields==1.9.3
django-filter==24.3 django-filter==24.3
django-import-export==4.1.1 django-import-export==4.1.1
django-iranian-cities==1.0.2 django-iranian-cities==1.0.2
django-jalali==7.3.0 django-jalali==7.3.0
django-storages==1.14.5
django-unfold==0.48.0 django-unfold==0.48.0
djangorestframework==3.15.2 djangorestframework==3.15.2
djangorestframework-simplejwt==5.3.1 djangorestframework-simplejwt==5.3.1
@@ -28,6 +39,7 @@ djoser==2.3.1
dnspython==2.7.0 dnspython==2.7.0
drf-spectacular==0.27.2 drf-spectacular==0.27.2
email_validator==2.2.0 email_validator==2.2.0
et-xmlfile==1.1.0
factory_boy==3.3.1 factory_boy==3.3.1
Faker==28.4.1 Faker==28.4.1
folium==0.19.4 folium==0.19.4
@@ -43,29 +55,40 @@ httpcore==1.0.5
httpx==0.27.2 httpx==0.27.2
idna==3.10 idna==3.10
inflection==0.5.1 inflection==0.5.1
isodate==0.6.1
jalali_core==1.0.0 jalali_core==1.0.0
jdatetime==5.0.0 jdatetime==5.0.0
Jinja2==3.1.5 Jinja2==3.1.5
jiter==0.8.2 jiter==0.8.2
jmespath==1.0.1
jsonschema==4.23.0 jsonschema==4.23.0
jsonschema-specifications==2024.10.1 jsonschema-specifications==2024.10.1
lxml==5.2.2
MarkupPy==1.14
MarkupSafe==3.0.2 MarkupSafe==3.0.2
maxminddb==2.6.2 maxminddb==2.6.2
multidict==6.1.0 multidict==6.1.0
numpy==2.2.3 numpy==2.2.3
oauthlib==3.2.2 oauthlib==3.2.2
odfpy==1.4.1
openai==1.58.1 openai==1.58.1
openpyxl==3.1.2
pillow==10.4.0 pillow==10.4.0
platformdirs==4.2.2
propcache==0.2.0
psutil==6.0.0 psutil==6.0.0
psycopg2-binary==2.9.10 psycopg2-binary==2.9.10
py-vapid==1.9.2 py-vapid==1.9.2
pycparser==2.22 pycparser==2.22
pycryptodome==3.20.0
pydantic==2.10.6 pydantic==2.10.6
pydantic_core==2.27.2 pydantic_core==2.27.2
PyJWT==2.10.1 PyJWT==2.10.1
pyTelegramBotAPI==4.23.0 pyTelegramBotAPI==4.23.0
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
python-decouple==3.8
python-dotenv==1.0.1 python-dotenv==1.0.1
python-slugify==8.0.4
python-telegram-bot==21.6 python-telegram-bot==21.6
python3-openid==3.2.0 python3-openid==3.2.0
pytz==2024.2 pytz==2024.2
@@ -73,20 +96,29 @@ pywebpush==2.0.3
PyYAML==6.0.2 PyYAML==6.0.2
referencing==0.35.1 referencing==0.35.1
requests==2.32.3 requests==2.32.3
requests-file==2.1.0
requests-oauthlib==2.0.0 requests-oauthlib==2.0.0
requests-toolbelt==1.0.0
rpds-py==0.20.0 rpds-py==0.20.0
s3transfer==0.11.2
setuptools==75.1.0 setuptools==75.1.0
six==1.16.0 six==1.16.0
sniffio==1.3.1 sniffio==1.3.1
social-auth-app-django==5.4.2 social-auth-app-django==5.4.2
social-auth-core==4.5.4 social-auth-core==4.5.4
soupsieve==2.5
sqlparse==0.5.1 sqlparse==0.5.1
tablib==3.5.0 tablib==3.5.0
telebot==0.0.5
text-unidecode==1.3
tqdm==4.67.1 tqdm==4.67.1
typing_extensions==4.12.2 typing_extensions==4.12.2
tzdata==2024.1 tzdata==2024.1
uritemplate==4.1.1 uritemplate==4.1.1
urllib3==2.2.3 urllib3==2.2.3
whitenoise==6.7.0 whitenoise==6.7.0
xlrd==2.0.1
xlwt==1.3.0
xyzservices==2025.1.0 xyzservices==2025.1.0
yarl==1.11.1 yarl==1.11.1
zeep==4.2.1
+5
View File
@@ -1,6 +1,11 @@
{% extends "admin/base.html" %} {% extends "admin/base.html" %}
{% load static %} {% load static %}
{% block html_attrs %}
lang="fa" dir="rtl"
{% endblock %}
{% block extrastyle %}{{ block.super }}<link rel="stylesheet" type="text/css" href="{% static 'override.css' %}" /> {% block extrastyle %}{{ block.super }}<link rel="stylesheet" type="text/css" href="{% static 'override.css' %}" />
<link rel="stylesheet" type="text/css" href="{% static 'fonts.css' %}" /> <link rel="stylesheet" type="text/css" href="{% static 'fonts.css' %}" />
{% endblock %} {% endblock %}
+7 -3
View File
@@ -5,6 +5,10 @@
{% endblock %} {% endblock %}
{% load i18n unfold %} {% load i18n unfold %}
{% block html_attrs %}
lang="fa" dir="rtl"
{% endblock %}
{% block breadcrumbs %}{% endblock %} {% block breadcrumbs %}{% endblock %}
{% block title %} {% block title %}
@@ -30,7 +34,7 @@
<div class="flex flex-col gap-8 lg:flex-row"> <div class="flex flex-col gap-8 lg:flex-row">
{% for stats in kpi %} {% for stats in kpi %}
{% component "unfold/components/card.html" with class="lg:w-1/3" label=_("Last 7 days") footer=stats.footer %} {% component "unfold/components/card.html" with class="lg:w-1/3" footer=stats.footer %}
{% component "unfold/components/text.html" %} {% component "unfold/components/text.html" %}
{{ stats.title }} {{ stats.title }}
{% endcomponent %} {% endcomponent %}
@@ -44,12 +48,12 @@
{% component "unfold/components/card.html" with title=_("Product performance in last 28 days") %} {% component "unfold/components/card.html" with title='بازدید های وبسایت در ماه اخیر' %}
{% component "unfold/components/chart/bar.html" with data=chart height=320 %}{% endcomponent %} {% component "unfold/components/chart/bar.html" with data=chart height=320 %}{% endcomponent %}
{% endcomponent %} {% endcomponent %}
<div class="flex flex-col gap-8 lg:flex-row"> <div class="flex flex-col gap-8 lg:flex-row">
{% component "unfold/components/card.html" with class="lg:w-1/2" title=_("The most trending products in last 2 weeks") %} {% component "unfold/components/card.html" with class="lg:w-1/2" title='محبوب ترین دسته بندی ها' %}
{% component "unfold/components/title.html" with class="mb-2" %} {% component "unfold/components/title.html" with class="mb-2" %}
$1,234,567.89 $1,234,567.89
{% endcomponent %} {% endcomponent %}
+2 -2
View File
@@ -12,7 +12,7 @@
<div class="flex lg:flex-row lg:items-center"> <div class="flex lg:flex-row lg:items-center">
{% component "unfold/components/flex.html" with class="flex-col gap-4 lg:flex-row" %} {% component "unfold/components/flex.html" with class="flex-col gap-4 lg:flex-row" %}
{% component "unfold/components/button.html" with href="/admin/order/ordermodel/" %} {% component "unfold/components/button.html" with href="/secret-admin/order/ordermodel/" %}
نمایش سفارشات نمایش سفارشات
{% endcomponent %} {% endcomponent %}
{% endcomponent %} {% endcomponent %}
@@ -33,7 +33,7 @@
<div class="flex lg:flex-row lg:items-center"> <div class="flex lg:flex-row lg:items-center">
{% component "unfold/components/flex.html" with class="flex-col gap-4 lg:flex-row" %} {% component "unfold/components/flex.html" with class="flex-col gap-4 lg:flex-row" %}
{% component "unfold/components/button.html" with href="/admin/ticket/ticket/" %} {% component "unfold/components/button.html" with href="/secret-admin/ticket/ticket/" %}
نمایش تیکت ها نمایش تیکت ها
{% endcomponent %} {% endcomponent %}
{% endcomponent %} {% endcomponent %}
+4
View File
@@ -59,3 +59,7 @@ class MessageAdmin(ModelAdmin, ImportExportModelAdmin):
def content_display(self, obj): def content_display(self, obj):
return obj.content[0:35] + '...' return obj.content[0:35] + '...'
content_display.short_description = 'محتوای پیام' content_display.short_description = 'محتوای پیام'
@admin.register(Attachment)
class AttachmentAdmin(ModelAdmin, ImportExportModelAdmin):
list_display = ['name', 'uploaded_by']
@@ -0,0 +1,18 @@
# Generated by Django 5.1.2 on 2025-02-21 18:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ticket', '0011_ticket_content'),
]
operations = [
migrations.AlterField(
model_name='ticket',
name='status',
field=models.CharField(choices=[('in_progress', 'در حال پردازش'), ('resolved', 'حل شده'), ('closed', 'بسته')], default='open', max_length=20, verbose_name='وضعیت تیکت'),
),
]
@@ -0,0 +1,33 @@
# Generated by Django 5.1.2 on 2025-02-22 22:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ticket', '0012_alter_ticket_status'),
]
operations = [
migrations.CreateModel(
name='Attachment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file', models.FileField(upload_to='attachments')),
('created_at', models.DateTimeField(auto_now_add=True)),
('size', models.PositiveIntegerField(blank=True, null=True)),
('name', models.CharField(blank=True, max_length=400, null=True)),
],
),
migrations.AddField(
model_name='message',
name='attachments',
field=models.ManyToManyField(blank=True, related_name='messages', to='ticket.attachment'),
),
migrations.AddField(
model_name='ticket',
name='attachments',
field=models.ManyToManyField(blank=True, related_name='tickets', to='ticket.attachment'),
),
]
@@ -0,0 +1,21 @@
# Generated by Django 5.1.2 on 2025-02-23 18:28
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ticket', '0013_attachment_message_attachments_ticket_attachments'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='attachment',
name='uploaded_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL),
),
]
+19 -1
View File
@@ -3,10 +3,26 @@ from account.models import User
from order.models import OrderModel from order.models import OrderModel
from django_jalali.db import models as jmodels from django_jalali.db import models as jmodels
class Attachment(models.Model):
file = models.FileField(upload_to='attachments')
created_at = models.DateTimeField(auto_now_add=True)
size = models.PositiveIntegerField(null=True, blank=True)
name = models.CharField(max_length=400, null=True, blank=True)
uploaded_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
def __str__(self):
return self.file.name
def save(self, *args, **kwargs):
if self.file:
self.size = self.file.size
self.name = self.file.name
super(Attachment, self).save(*args, **kwargs)
class Ticket(models.Model): class Ticket(models.Model):
objects = jmodels.jManager() objects = jmodels.jManager()
STATUS_CHOICES = [ STATUS_CHOICES = [
('open', 'باز'),
('in_progress', 'در حال پردازش'), ('in_progress', 'در حال پردازش'),
('resolved', 'حل شده'), ('resolved', 'حل شده'),
('closed', 'بسته'), ('closed', 'بسته'),
@@ -29,6 +45,7 @@ class Ticket(models.Model):
created_at = jmodels.jDateTimeField(auto_now_add=True, verbose_name='ساخته شده در') created_at = jmodels.jDateTimeField(auto_now_add=True, verbose_name='ساخته شده در')
updated_at = jmodels.jDateTimeField(auto_now=True, verbose_name='اپدیت شده در') updated_at = jmodels.jDateTimeField(auto_now=True, verbose_name='اپدیت شده در')
order = models.ForeignKey(OrderModel ,blank=True, null=True, on_delete=models.SET_NULL) order = models.ForeignKey(OrderModel ,blank=True, null=True, on_delete=models.SET_NULL)
attachments = models.ManyToManyField(Attachment, related_name='tickets', blank=True)
def __str__(self): def __str__(self):
return self.subject return self.subject
@@ -45,6 +62,7 @@ class Message(models.Model):
sender = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name='ارسال کننده') sender = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name='ارسال کننده')
content = models.TextField(verbose_name='محتوای پیام') content = models.TextField(verbose_name='محتوای پیام')
created_at = jmodels.jDateTimeField(auto_now_add=True, verbose_name='ساخته شده در') created_at = jmodels.jDateTimeField(auto_now_add=True, verbose_name='ساخته شده در')
attachments = models.ManyToManyField(Attachment, related_name='messages', blank=True)
def __str__(self): def __str__(self):
return f"Message by {self.sender.full_name} on {self.ticket.subject}" return f"Message by {self.sender.full_name} on {self.ticket.subject}"
+22 -4
View File
@@ -1,7 +1,10 @@
from rest_framework import serializers from rest_framework import serializers
from .models import Ticket, Message from .models import Ticket, Message, Attachment
from django.utils.timezone import localtime from django.utils.timezone import localtime
from account.serializers import ProfileSerializer from account.serializers import ProfileSerializer
class MessageSerializer(serializers.ModelSerializer): class MessageSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Message model = Message
@@ -17,8 +20,23 @@ class TicketSerializer(serializers.ModelSerializer):
class TicketListSerializer(serializers.ModelSerializer): class TicketListSerializer(serializers.ModelSerializer):
admin = ProfileSerializer(read_only=True)
class Meta: class Meta:
model = Ticket model = Ticket
exclude = ('customer', ) exclude = ('customer', 'admin', 'order', 'content')
read_only_fields = ('status', 'admin', ) read_only_fields = ('status',)
class AttachmentSerializer(serializers.ModelSerializer):
file = serializers.FileField(write_only=True)
link = serializers.SerializerMethodField()
class Meta:
model = Attachment
fields = ['id', 'name', 'file','link' , 'created_at', 'size']
read_only_fields = ('size', 'name', )
def get_link(self, obj):
request = self.context.get('request')
if request is not None:
return request.build_absolute_uri(obj.file.url)
return obj.file.url
+7 -11
View File
@@ -1,15 +1,11 @@
from django.urls import path from django.urls import path
from .views import ( from . import views
TicketCreateView,
TicketListView,
TicketDetailView,
MessageCreateView,
UpdateTicketStatusView
)
urlpatterns = [ urlpatterns = [
path('create', TicketCreateView.as_view(), name='ticket-create'), path('create', views.TicketCreateView.as_view(), name='ticket-create'),
path('', TicketListView.as_view(), name='ticket-list'), path('', views.TicketListView.as_view(), name='ticket-list'),
path('<int:pk>', TicketDetailView.as_view(), name='ticket-detail'), path('<int:pk>', views.TicketDetailView.as_view(), name='ticket-detail'),
path('message/<int:pk>', MessageCreateView.as_view(), name='message-create'), path('message/create', views.MessageCreateView.as_view(), name='message-create'),
path('attachment/create', views.AttachmentUploadView.as_view(), name='attachment-upload'),
path('attachment/delete/<int:pk>', views.AttachmentDeleteView.as_view(), name='attachment-upload'),
] ]
+64 -3
View File
@@ -1,10 +1,44 @@
from rest_framework import generics, permissions from rest_framework import generics, permissions
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from .models import Ticket, Message from .models import Ticket, Message, Attachment
from .serializers import TicketListSerializer, MessageSerializer, TicketSerializer from .serializers import TicketListSerializer, MessageSerializer, TicketSerializer, AttachmentSerializer
from utils.pagination import StructurePagination from utils.pagination import StructurePagination
from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes
from rest_framework.permissions import IsAuthenticated
from rest_framework.parsers import MultiPartParser, FormParser
from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiExample, OpenApiTypes, OpenApiResponse
from rest_framework import status
from django.shortcuts import get_object_or_404
class AttachmentDeleteView(APIView):
permission_classes = [IsAuthenticated]
serializer_class = [AttachmentSerializer]
def delete(self, request, pk):
attachment_instance = get_object_or_404(Attachment, pk=pk)
if attachment_instance.uploaded_by == request.user:
attachment_instance.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
return Response({'detail': 'این فایل توسط شما اپلود نشده'})
class AttachmentUploadView(APIView):
permission_classes = [IsAuthenticated]
parser_classes = [MultiPartParser, FormParser]
@extend_schema(
request=AttachmentSerializer,
responses={201: AttachmentSerializer},
description="upload an attachment (file).",
)
def post(self, request, *args, **kwargs):
serializer = AttachmentSerializer(data=request.data, context={'request': request})
if serializer.is_valid():
serializer.save(uploaded_by=request.user)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class TicketCreateView(generics.CreateAPIView): class TicketCreateView(generics.CreateAPIView):
queryset = Ticket.objects.all() queryset = Ticket.objects.all()
@@ -33,7 +67,26 @@ class TicketListView(APIView):
description="افستش", description="افستش",
required=False, required=False,
type=OpenApiTypes.INT, type=OpenApiTypes.INT,
) ),
OpenApiParameter(
name="filter",
description=(
"filter results by one of the following fields:\n"
"`in_progress`, `closed`, `resolved`."
),
required=False,
type=OpenApiTypes.STR,
),
OpenApiParameter(
name="sort",
description=(
"Sort results by one of the following fields:\n"
" `created_at`, `-created_at`."
"\nPrefix with `-` for descending order."
),
required=False,
type=OpenApiTypes.STR,
),
], ],
responses={ responses={
200: TicketListSerializer(many=True), 200: TicketListSerializer(many=True),
@@ -42,6 +95,14 @@ class TicketListView(APIView):
) )
def get(self, request): def get(self, request):
tickets = Ticket.objects.filter(customer=request.user) tickets = Ticket.objects.filter(customer=request.user)
filter_by = request.query_params.get('filter', None)
sort = request.query_params.get('sort', None)
if filter_by:
tickets.filter(status=str(filter_by))
if sort:
if sort not in ['created_at', '-created_at']:
return Response({'detail': 'wrong sort paramter'}, status=status.HTTP_400_BAD_REQUEST)
tickets.order_by(sort)
paginator = self.pagination_class() paginator = self.pagination_class()
paginated_tickets = paginator.paginate_queryset(tickets, request) paginated_tickets = paginator.paginate_queryset(tickets, request)
tickets_ser = self.serializer_class(instance=paginated_tickets, many=True, context={'request': request}) tickets_ser = self.serializer_class(instance=paginated_tickets, many=True, context={'request': request})
+39
View File
@@ -0,0 +1,39 @@
#!/bin/sh
# Load environment variables
. /etc/profile
# Debugging output
echo "Cron environment:" >> /var/log/cron/cron.log
env >> /var/log/cron/cron.log
echo "Starting backup at $(date)" >> /var/log/cron/cron.log
export PGPASSWORD=$PG_PASSWORD
TELEGRAM_API="https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="backup_heymlz_shop${TIMESTAMP}.sql"
ZIP_FILE="backup_heymlz_shop${TIMESTAMP}.zip"
pg_dump -h $PG_HOST -p $PG_PORT -U $PG_USER -d $PG_DATABASE -w > /backups/$BACKUP_FILE
if [ $? -ne 0 ]; then
echo "Backup failed!" >> /var/log/cron/cron.log
exit 1
fi
zip -j /backups/$ZIP_FILE /backups/$BACKUP_FILE
curl -F chat_id=$TELEGRAM_CHAT_ID \
-F document=@/backups/$ZIP_FILE \
$TELEGRAM_API
curl -F chat_id=$TELEGRAM_CHAT_ID2 \
-F document=@/backups/$ZIP_FILE \
$TELEGRAM_API
ls -t /backups/*.zip | tail -n +4 | xargs rm -f
ls -t /backups/*.sql | tail -n +4 | xargs rm -f
echo "Backup completed at $(date)" >> /var/log/cron/cron.log
+1
View File
@@ -0,0 +1 @@
0 3 * * * /app/backup.sh >> /var/log/cron/cron.log 2>&1
+15
View File
@@ -0,0 +1,15 @@
FROM alpine:3.19
RUN apk add --no-cache postgresql-client curl zip tzdata
RUN ln -sf /usr/share/zoneinfo/UTC /etc/localtime
RUN mkdir -p /var/log/cron /backups
WORKDIR /app
COPY backup.sh .
COPY crontab /etc/crontabs/root
RUN chmod +x backup.sh && \
chmod 0644 /etc/crontabs/root && \
touch /var/log/cron/cron.log
CMD ["busybox", "crond", "-f", "-L", "/var/log/cron/cron.log"]
+19 -2
View File
@@ -42,11 +42,28 @@ services:
networks: networks:
- default - default
db-backup:
build:
context: ./backup
depends_on:
- db
environment:
- PG_HOST=db
- PG_PORT=5432
- PG_DATABASE=hshop
- PG_USER=byeto
- PG_PASSWORD=vuhbyq-cypMu0-sirbon
- TELEGRAM_BOT_TOKEN=7068288679:AAGecMnyt9A6R78OQu8nQeISMK1LepX718g
- TELEGRAM_CHAT_ID=1198382521
- TELEGRAM_CHAT_ID2=5115366609
volumes:
- backups:/backups
networks:
- default
volumes: volumes:
postgres_data: postgres_data:
media_data: media_data:
backups:
networks: networks:
default: default:
+41 -27
View File
@@ -6,11 +6,19 @@ import { useToast } from "~/composables/global/useToast";
// types // types
type Props = { type Props = {
modelValue: File[]; modelValue: {
id: number;
file_link: string;
date: string;
size: number;
name: string;
}[];
loading?: boolean;
}; };
type Emits = { type Emits = {
"update:modelValue": [value: any]; "update:modelValue": [value: any];
change: [value: File];
}; };
// props // props
@@ -50,7 +58,8 @@ const onDrop = (files: File[] | null) => {
} else { } else {
if (modelValue.value.length + files.length <= 3) { if (modelValue.value.length + files.length <= 3) {
files?.forEach((item) => { files?.forEach((item) => {
emit("update:modelValue", [...modelValue.value, item]); emit("change", item);
resetFileDialog();
}); });
} else { } else {
addToast({ addToast({
@@ -78,22 +87,27 @@ const { isOverDropZone } = useDropZone(dropZoneRef, {
dataTypes: ["image/jpeg", "image/png", "image/jpg"], dataTypes: ["image/jpeg", "image/png", "image/jpg"],
}); });
const { open: openDialog, onChange } = useFileDialog({ const {
open: openDialog,
onChange,
reset: resetFileDialog,
} = useFileDialog({
accept: "image/*", accept: "image/*",
directory: false, directory: false,
}); });
onChange((files: any) => { onChange((files: any) => {
let arr: File[] = []; let arr: File[] = [];
Object.keys(files).forEach((item) => { Object.keys(files).forEach((_, index) => {
arr.push(files[item]); arr.push(files[index]);
}); });
onDrop(arr); onDrop(arr);
}); });
const deleteFile = (index: number) => { const removeAttachment = (id: number) => {
let target = modelValue.value.findIndex((i) => i.id == id);
const clone = [...modelValue.value]; const clone = [...modelValue.value];
clone.splice(index, 1); clone.splice(target, 1);
emit("update:modelValue", clone); emit("update:modelValue", clone);
}; };
</script> </script>
@@ -103,10 +117,22 @@ const deleteFile = (index: number) => {
<div <div
ref="dropZoneRef" ref="dropZoneRef"
@click="openDialog" @click="openDialog"
class="bg-slate-50 flex-col-center w-full transition-all text-black/50 gap-3 h-[20rem] border border-dashed rounded-xl cursor-pointer select-none" class="bg-slate-50 relative flex-col-center w-full transition-all text-black/50 gap-3 h-[20rem] border border-dashed rounded-xl cursor-pointer select-none"
:class="isOverDropZone ? 'border-black' : ' border-slate-300'" :class="{
'border-black': isOverDropZone,
' border-slate-300': !isOverDropZone,
'pointer-events-none': loading,
}"
> >
<Icon name="bi:file-earmark-arrow-down" size="32" /> <Icon
:name="
loading
? 'svg-spinners:ring-resize'
: 'bi:file-earmark-arrow-down'
"
size="32"
:class="loading ? '' : ''"
/>
<p class="font-bold text-dynamic-primary text-sm lg:text-[1rem]"> <p class="font-bold text-dynamic-primary text-sm lg:text-[1rem]">
برای آپلود کلیک کنید یا فایل خود را اینجا بیاندازید برای آپلود کلیک کنید یا فایل خود را اینجا بیاندازید
</p> </p>
@@ -121,24 +147,12 @@ const deleteFile = (index: number) => {
v-auto-animate v-auto-animate
class="flex flex-row-reverse items-center justify-between w-full px-2 animate__animated animate__fadeIn" class="flex flex-row-reverse items-center justify-between w-full px-2 animate__animated animate__fadeIn"
> >
<li class="w-full flex items-center"> <NewAttachment
<div class="flex justify-end w-9/12"> :id="item.id"
<p class="text-sm text-black">{{ item.name }}</p> :size="item.size"
</div> :name="item.name"
<div class="w-2/12"> @delete="removeAttachment"
<p class="text-sm text-black">
{{ (item.size / 1024).toFixed(2) }}KB
</p>
</div>
<div class="w-1/12">
<Icon
name="ci:close"
class="**:stroke-red-500 cursor-pointer pb-1"
@click="deleteFile(index)"
size="28"
/> />
</div>
</li>
</ul> </ul>
</div> </div>
</template> </template>
+10 -11
View File
@@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
// types // types
type Props = { type Props = {
@@ -18,16 +17,12 @@ defineProps<Props>();
const params: any = inject("params"); const params: any = inject("params");
const page = ref(params?.page ? Number(params.page) : 1); // computed
// watch const page = computed({
get: () => (params?.page ? Number(params.page) : 1),
watch( set: (value: number) => (params.page = value),
() => page.value, });
(newPage) => {
params.page = newPage;
}
);
</script> </script>
<template> <template>
@@ -47,7 +42,11 @@ watch(
<PaginationNext <PaginationNext
class="w-9 h-9 ml-4 flex items-center justify-center cursor-pointer hover:bg-slate-100 transition disabled:opacity-50 disabled:cursor-not-allowed rounded-lg" class="w-9 h-9 ml-4 flex items-center justify-center cursor-pointer hover:bg-slate-100 transition disabled:opacity-50 disabled:cursor-not-allowed rounded-lg"
> >
<Icon name="ci:chevron-right" class="**:fill-back" size="18px" /> <Icon
name="ci:chevron-right"
class="**:fill-back"
size="18px"
/>
</PaginationNext> </PaginationNext>
<template v-for="(page, index) in items"> <template v-for="(page, index) in items">
+1 -1
View File
@@ -13,7 +13,7 @@ defineProps<Props>();
<template> <template>
<div <div
class="w-full flex-col flex-grow py-[12rem] gap-6 border-2 border-slate-200 border-dashed size-full rounded-100 flex-center" class="w-full flex-col flex-grow py-[12rem] gap-6 border-2 border-slate-200 border-dashed size-full rounded-xl flex-center"
> >
<Icon :name="icon" size="50" class="**:fill-gray-500" /> <Icon :name="icon" size="50" class="**:fill-gray-500" />
<span class="text-lg text-gray-500"> {{ title }} </span> <span class="text-lg text-gray-500"> {{ title }} </span>
@@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
// import // import
import type { ToastOptions } from "~/composables/global/useToast"; import type { ToastOptions } from "~/composables/global/useToast";
@@ -9,9 +8,9 @@ import { useToast } from "~/composables/global/useToast";
type Props = { type Props = {
id: number; id: number;
message: string, message: string;
options: ToastOptions options: ToastOptions;
} };
// props // props
@@ -24,7 +23,7 @@ const { destroyToast } = useToast();
const open = ref(true); const open = ref(true);
// method // methods
const onSwipeEnd = () => { const onSwipeEnd = () => {
setTimeout(() => { setTimeout(() => {
@@ -41,37 +40,39 @@ const statusIcon = computed(() => {
case "success": case "success":
return { return {
name: "duo-icons:check-circle", name: "duo-icons:check-circle",
class: "**:fill-success-500 [filter:drop-shadow(0_0_10px_var(--color-success-500))]" class: "**:fill-success-500 [filter:drop-shadow(0_0_10px_var(--color-success-500))]",
}; };
case "error": case "error":
return { return {
name: "duo-icons:alert-triangle", name: "duo-icons:alert-triangle",
class: "**:fill-danger-500 [filter:drop-shadow(0_0_10px_var(--color-danger-500))]" class: "**:fill-danger-500 [filter:drop-shadow(0_0_10px_var(--color-danger-500))]",
}; };
case "info": case "info":
return { return {
name: "duo-icons:info", name: "duo-icons:info",
class: "**:fill-cyan-500 [filter:drop-shadow(0_0_10px_var(--color-cyan-500))]" class: "**:fill-cyan-500 [filter:drop-shadow(0_0_10px_var(--color-cyan-500))]",
}; };
case "warning": case "warning":
return { return {
name: "duo-icons:alert-octagon", name: "duo-icons:alert-octagon",
class: "**:fill-warning-500 [filter:drop-shadow(0_0_10px_var(--color-warning-500))]" class: "**:fill-warning-500 [filter:drop-shadow(0_0_10px_var(--color-warning-500))]",
}; };
default: default:
return { return {
name: "duo-icons:info", name: "duo-icons:info",
class: "**:fill-slate-500 [filter:drop-shadow(0_0_10px_var(--color-slate-500))]" class: "**:fill-slate-500 [filter:drop-shadow(0_0_10px_var(--color-slate-500))]",
}; };
} }
}); });
// watch // watch
watch(() => open.value, (value) => { watch(
() => open.value,
(value) => {
if (!value) onSwipeEnd(); if (!value) onSwipeEnd();
}); }
);
// lifecycle // lifecycle
@@ -80,7 +81,6 @@ onMounted(() => {
open.value = false; open.value = false;
}, options.value.duration ?? 4000); }, options.value.duration ?? 4000);
}); });
</script> </script>
<template> <template>
@@ -1,11 +1,10 @@
<script lang="ts" setup> <script lang="ts" setup>
// types // types
type Props = { type Props = {
selectedSlide: number; selectedSlide: number;
slides: ProductImage[] slides: ProductImage[];
} };
// props // props
@@ -23,17 +22,18 @@ const selectedSlideDetail = computed(() => {
})!; })!;
}); });
// method // methods
const changeSlide = (id: number) => { const changeSlide = (id: number) => {
emit("update:selectedSlide", id); emit("update:selectedSlide", id);
}; };
</script> </script>
<template> <template>
<div class="flex flex-col relative gap-6"> <div class="flex flex-col relative gap-6">
<div class="bg-white brightness-[97%] w-full relative aspect-square overflow-hidden rounded-200"> <div
class="bg-white brightness-[97%] w-full relative aspect-square overflow-hidden rounded-200"
>
<Transition name="zoom" mode="out-in"> <Transition name="zoom" mode="out-in">
<img <img
:key="selectedSlideDetail.id" :key="selectedSlideDetail.id"
@@ -47,7 +47,11 @@ const changeSlide = (id: number) => {
<div <div
@click="changeSlide(slide.id)" @click="changeSlide(slide.id)"
v-for="slide in slides" v-for="slide in slides"
:class="selectedSlide === slide.id ? '!ring-black' : 'ring-transparent'" :class="
selectedSlide === slide.id
? '!ring-black'
: 'ring-transparent'
"
class="active:scale-95 hover:ring-slate-200 transition-all cursor-pointer brightness-[97%] bg-white aspect-square ring-2 ring-offset-4 rounded-200 w-full overflow-hidden relative" class="active:scale-95 hover:ring-slate-200 transition-all cursor-pointer brightness-[97%] bg-white aspect-square ring-2 ring-offset-4 rounded-200 w-full overflow-hidden relative"
:key="slide.id" :key="slide.id"
> >
@@ -29,17 +29,17 @@ const {
isPending: isChatPending, isPending: isChatPending,
isFetchingNextPage: isNextChatPagePending, isFetchingNextPage: isNextChatPagePending,
hasNextPage: hasMoreChat, hasNextPage: hasMoreChat,
fetchNextPage: loadMoreChat fetchNextPage: loadMoreChat,
} = useGetChat(id, isOpen); } = useGetChat(id, isOpen);
const isCreateMessagePending = useIsMutating({ const isCreateMessagePending = useIsMutating({
mutationKey: [MUTATION_KEYS.create_chat] mutationKey: [MUTATION_KEYS.create_chat],
}); });
const canLoadMoreChat = ref(false); const canLoadMoreChat = ref(false);
const isChatScrollLocked = useScrollLock(chatContainerEl); const isChatScrollLocked = useScrollLock(chatContainerEl);
const { y: chatContainerScrollY } = useScroll(chatContainerEl, { const { y: chatContainerScrollY } = useScroll(chatContainerEl, {
behavior: "smooth" behavior: "smooth",
}); });
useInfiniteScroll( useInfiniteScroll(
@@ -56,11 +56,11 @@ useInfiniteScroll(
distance: 10, distance: 10,
direction: "top", direction: "top",
throttle: 1000, throttle: 1000,
canLoadMore: () => canLoadMoreChat.value canLoadMore: () => canLoadMoreChat.value,
} }
); );
// method // methods
const scrollToBottom = () => { const scrollToBottom = () => {
chatContainerScrollY.value = chatContainerEl.value?.scrollHeight ?? 0; chatContainerScrollY.value = chatContainerEl.value?.scrollHeight ?? 0;
@@ -116,7 +116,7 @@ whenever(
}, 2000); }, 2000);
}, },
{ {
once: true once: true,
} }
); );
</script> </script>
@@ -131,7 +131,6 @@ whenever(
<template v-if="isLoggedIn"> <template v-if="isLoggedIn">
<Transition name="zoom" mode="out-in"> <Transition name="zoom" mode="out-in">
<div <div
v-if="!isChatPending" v-if="!isChatPending"
class="p-4.5 h-full flex flex-col justify-between gap-4" class="p-4.5 h-full flex flex-col justify-between gap-4"
@@ -148,7 +147,10 @@ whenever(
v-if="hasMoreChat" v-if="hasMoreChat"
class="py-2 flex items-center justify-center" class="py-2 flex items-center justify-center"
> >
<Icon name="svg-spinners:3-dots-fade" size="24" /> <Icon
name="svg-spinners:3-dots-fade"
size="24"
/>
</div> </div>
<ChatMessage <ChatMessage
v-for="(message, index) in chatMessages" v-for="(message, index) in chatMessages"
@@ -173,7 +175,6 @@ whenever(
<ChatInput /> <ChatInput />
</div> </div>
<div <div
v-else v-else
class="w-full h-full flex items-center justify-center absolute inset-0" class="w-full h-full flex items-center justify-center absolute inset-0"
@@ -182,7 +183,10 @@ whenever(
</div> </div>
</Transition> </Transition>
</template> </template>
<div class="text-black p-4.5 size-full flex justify-center items-center" v-else> <div
class="text-black p-4.5 size-full flex justify-center items-center"
v-else
>
Please sign in first Please sign in first
</div> </div>
</div> </div>
@@ -1,24 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
// state // state
const isOpen = ref(false); const isOpen = ref(false);
// method // methods
const closeChat = () => isOpen.value = false; const closeChat = () => (isOpen.value = false);
// provide-inject // provide-inject
provide("isOpen", { provide("isOpen", {
isOpen, isOpen,
closeChat closeChat,
}); });
</script> </script>
<template> <template>
<button <button
v-if="!isOpen" v-if="!isOpen"
@click="isOpen = !isOpen" @click="isOpen = !isOpen"
@@ -32,5 +29,4 @@ provide("isOpen", {
</button> </button>
<ChatBoxContainer :isOpen="isOpen" /> <ChatBoxContainer :isOpen="isOpen" />
</template> </template>
@@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
// types // types
import AiLoading from "~/components/product/ChatBox/AiLoading.vue"; import AiLoading from "~/components/product/ChatBox/AiLoading.vue";
@@ -12,12 +11,12 @@ const { $queryClient: queryClient } = useNuxtApp();
const { addToast } = useToast(); const { addToast } = useToast();
const { mutateAsync: createMessage, isPending: isCreatingMessage } = useCreateChatMessage(queryClient); const { mutateAsync: createMessage, isPending: isCreatingMessage } =
useCreateChatMessage(queryClient);
const chatInputEl = ref<HTMLInputElement | null>(null); const chatInputEl = ref<HTMLInputElement | null>(null);
// methods
// method
const sendMessage = async () => { const sendMessage = async () => {
const value = chatInputEl.value!.value; const value = chatInputEl.value!.value;
@@ -28,20 +27,18 @@ const sendMessage = async () => {
await createMessage({ await createMessage({
new_message: value, new_message: value,
productId: 1 productId: 1,
}); });
} catch (e) { } catch (e) {
addToast({ addToast({
message: "مشکلی پیش آمده", message: "مشکلی پیش آمده",
options: { options: {
status: "error" status: "error",
} },
}); });
} }
} }
}; };
</script> </script>
<template> <template>
@@ -71,12 +68,20 @@ const sendMessage = async () => {
<form <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="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'" :class="
isCreatingMessage
? 'border-transparent shadow-black/10 bg-white/85 backdrop-blur-xl'
: 'border-slate-200 shadow-transparent bg-white'
"
> >
<input <input
ref="chatInputEl" ref="chatInputEl"
:disabled="isCreatingMessage" :disabled="isCreatingMessage"
:placeholder="isCreatingMessage ? 'دارم فکر میکنم...' : 'سوال خود را بپرسید'" :placeholder="
isCreatingMessage
? 'دارم فکر میکنم...'
: 'سوال خود را بپرسید'
"
type="text" type="text"
name="text" name="text"
class="focus:outline-none h-full typo-p-sm w-full border-none" class="focus:outline-none h-full typo-p-sm w-full border-none"
@@ -89,13 +94,21 @@ const sendMessage = async () => {
:class="isCreatingMessage ? 'bg-transparent' : 'bg-black'" :class="isCreatingMessage ? 'bg-transparent' : 'bg-black'"
> >
<TransitionGroup name="fade-down"> <TransitionGroup name="fade-down">
<AiLoading v-if="isCreatingMessage" circle :size="75" class="mb-1" /> <AiLoading
<Icon v-else name="iconamoon:send-light" class="absolute rotate-180 **:stroke-white" /> v-if="isCreatingMessage"
circle
:size="75"
class="mb-1"
/>
<Icon
v-else
name="iconamoon:send-light"
class="absolute rotate-180 **:stroke-white"
/>
</TransitionGroup> </TransitionGroup>
</button> </button>
</form> </form>
</div> </div>
</div> </div>
</template> </template>
@@ -1,14 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
// types // types
type Props = { type Props = {
id: number, id: number;
reverse?: boolean, reverse?: boolean;
content: string, content: string;
isLast?: boolean, isLast?: boolean;
loadingContent?: boolean loadingContent?: boolean;
} };
// props // props
@@ -23,20 +22,24 @@ const emit = defineEmits(["textUpdate"]);
const { $gsap: gsap } = useNuxtApp(); const { $gsap: gsap } = useNuxtApp();
// method // methods
const showMessage = () => { const showMessage = () => {
gsap.fromTo(`#message-container-${id.value}`, { gsap.fromTo(
`#message-container-${id.value}`,
{
rotateX: -50, rotateX: -50,
translateY: -40, translateY: -40,
opacity: 0 opacity: 0,
}, { },
{
rotateX: 0, rotateX: 0,
translateY: 0, translateY: 0,
opacity: 1, opacity: 1,
duration: 0.5, duration: 0.5,
ease: "expo.out" ease: "expo.out",
}); }
);
}; };
// lifecycle // lifecycle
@@ -47,19 +50,22 @@ onMounted(() => {
} }
if (reverse.value && isLast.value) { if (reverse.value && isLast.value) {
gsap.fromTo(`#chat-message-content-${id.value}`, { gsap.fromTo(
`#chat-message-content-${id.value}`,
{
text: "", text: "",
duration: 2.5, duration: 2.5,
ease: "none" ease: "none",
}, { },
{
text: { value: content.value, rtl: false }, text: { value: content.value, rtl: false },
duration: 2.5, duration: 2.5,
ease: "none", ease: "none",
onUpdate: () => emit("textUpdate") onUpdate: () => emit("textUpdate"),
}); }
);
} }
}); });
</script> </script>
<template> <template>
@@ -81,7 +87,11 @@ onMounted(() => {
</div> </div>
<div <div
class="rounded-150 px-4 py-3" class="rounded-150 px-4 py-3"
:class="reverse ? 'bg-slate-100 text-slate-600' : 'bg-black text-white'" :class="
reverse
? 'bg-slate-100 text-slate-600'
: 'bg-black text-white'
"
> >
<p <p
v-if="!loadingContent" v-if="!loadingContent"
@@ -91,11 +101,7 @@ onMounted(() => {
{{ content }} {{ content }}
</p> </p>
<Icon <Icon v-else name="svg-spinners:3-dots-bounce" size="20" />
v-else
name="svg-spinners:3-dots-bounce"
size="20"
/>
</div> </div>
</div> </div>
</div> </div>
+13 -15
View File
@@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
// import // import
import useGetComments from "~/composables/api/product/useGetComments"; import useGetComments from "~/composables/api/product/useGetComments";
@@ -17,14 +16,15 @@ const { token } = useAuth();
const userComment = ref(""); const userComment = ref("");
const { data: comments, refetch: refetchComments } = useGetComments(id, page); const { data: comments, refetch: refetchComments } = useGetComments(id, page);
const { mutateAsync: createComment, isPending: isCreateCommentPending } = useCreateComment(id); const { mutateAsync: createComment, isPending: isCreateCommentPending } =
useCreateComment(id);
// method // methods
const submitComment = async () => { const submitComment = async () => {
if (userComment.value.length > 3) { if (userComment.value.length > 3) {
await createComment({ await createComment({
content: userComment.value content: userComment.value,
}); });
userComment.value = ""; userComment.value = "";
@@ -32,23 +32,25 @@ const submitComment = async () => {
await refetchComments(); await refetchComments();
} }
}; };
</script> </script>
<template> <template>
<section class="bg-slate-50"> <section class="bg-slate-50">
<div class="flex relative gap-8 my-42 container"> <div class="flex relative gap-8 my-42 container">
<div class="sticky top-0 flex flex-col gap-6 min-w-[400px] max-h-min bg-white p-8 rounded-xl border-[0.5px] border-slate-200"> <div
<h3 class="typo-h-3"> class="sticky top-0 flex flex-col gap-6 min-w-[400px] max-h-min bg-white p-8 rounded-xl border-[0.5px] border-slate-200"
نظرات کاربران >
</h3> <h3 class="typo-h-3">نظرات کاربران</h3>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<Rating /> <Rating />
<span class="typo-p-sm"> <span class="typo-p-sm">
بر اساس {{ comments?.count }} نظر بر اساس {{ comments?.count }} نظر
</span> </span>
</div> </div>
<form @submit.prevent="submitComment" class="flex flex-col gap-6"> <form
@submit.prevent="submitComment"
class="flex flex-col gap-6"
>
<textarea <textarea
:disabled="!token" :disabled="!token"
class="w-full min-h-[200px] field-sizing-content rounded-xl bg-white p-4 border border-slate-200" class="w-full min-h-[200px] field-sizing-content rounded-xl bg-white p-4 border border-slate-200"
@@ -65,10 +67,7 @@ const submitComment = async () => {
نظر بنویسید نظر بنویسید
</Button> </Button>
<NuxtLink v-else to="/signin"> <NuxtLink v-else to="/signin">
<Button <Button type="button" class="rounded-full w-full">
type="button"
class="rounded-full w-full"
>
وارد شوید وارد شوید
</Button> </Button>
</NuxtLink> </NuxtLink>
@@ -90,7 +89,6 @@ const submitComment = async () => {
/> />
</div> </div>
</div> </div>
</div> </div>
</section> </section>
</template> </template>
+17 -6
View File
@@ -52,17 +52,28 @@ await suspense();
<div class="flex items-center justify-between w-full"> <div class="flex items-center justify-between w-full">
<div class="flex flex-col items-start gap-1"> <div class="flex flex-col items-start gap-1">
<span class="flex font-semibold text-dynamic-primary"> <span class="flex font-semibold text-dynamic-primary">
<p> <p v-if="account?.first_name">
{{ `${account?.first_name} ${account?.last_name}` }} {{ `${account?.first_name} ${account?.last_name}` }}
</p> </p>
<p v-else>بدون نام کاربری</p>
</span> </span>
<button class="text-xs font-semibold text-cyan-500"> <NuxtLink
:to="{ name: 'profile' }"
class="text-xs font-semibold text-cyan-500"
>
ویرایش اطلاعات ویرایش اطلاعات
</button> </NuxtLink>
</div> </div>
<img <Avatar
src="https://shatelpart.com/storage/users/user-15-155Mn1.png" class="!size-[3rem]"
class="rounded-full size-[3rem] hover:border-dynamic-rose transition" :src="account!.profile_photo"
:alt="
account?.first_name && account?.last_name
? `${account?.first_name.charAt(
0
)} ${account?.last_name.charAt(0)}`
: 'بدون نام کاربری'
"
/> />
</div> </div>
</div> </div>
@@ -1,4 +1,14 @@
<script setup lang="ts"></script> <script setup lang="ts">
// types
type Props = {
data: Ticket;
};
// props
defineProps<Props>();
</script>
<template> <template>
<tr <tr
@@ -8,13 +18,15 @@
scope="row" scope="row"
class="w-3/12 px-6 py-6 font-medium whitespace-nowrap text-black" class="w-3/12 px-6 py-6 font-medium whitespace-nowrap text-black"
> >
Apple MacBook Pro 17 {{ data.ticket_category }}
</td> </td>
<td class="w-3/12 px-6 py-6">Silver</td> <td class="w-3/12 px-6 py-6">{{ data.subject }}</td>
<td class="w-3/12 px-6 py-6">Laptop</td> <td class="w-3/12 px-6 py-6">{{ data.created_at }}</td>
<td class="w-2/12 px-6 py-6">$2999</td> <td class="w-2/12 px-6 py-6">{{ data.status }}</td>
<td class="w-1/12 px-6 py-6"> <td class="w-1/12 px-6 py-6">
<NuxtLink :to="{ name: 'profile-tickets-id', params: { id: 1 } }"> <NuxtLink
:to="{ name: 'profile-tickets-id', params: { id: data.id } }"
>
<button <button
class="size-10 flex-center border border-slate-200 rounded-md" class="size-10 flex-center border border-slate-200 rounded-md"
> >
@@ -0,0 +1,28 @@
<script setup lang="ts"></script>
<template>
<tr
class="odd:bg-white even:bg-gray-50 last:border-none border-b border-slate-200"
>
<td
scope="row"
class="w-3/12 px-6 py-6 font-medium whitespace-nowrap text-black"
>
<Skeleton class="w-full !h-10 !rounded-sm" />
</td>
<td class="w-3/12 px-6 py-6">
<Skeleton class="w-full !h-10 !rounded-sm" />
</td>
<td class="w-3/12 px-6 py-6">
<Skeleton class="w-full !h-10 !rounded-sm" />
</td>
<td class="w-2/12 px-6 py-6">
<Skeleton class="w-full !h-10 !rounded-sm" />
</td>
<td class="w-1/12 px-6 py-6">
<Skeleton class="!size-10 !rounded-sm" />
</td>
</tr>
</template>
<style scoped></style>
@@ -0,0 +1,74 @@
<script setup lang="ts">
// imports
import useDeleteAttachment from "~/composables/api/tickets/useDeleteAttachment";
// types
type Props = {
id: number;
size: number;
name: string;
};
type Emits = {
delete: [value: number];
};
// props
defineProps<Props>();
// Emits
const emit = defineEmits<Emits>();
// queries
const { mutateAsync: deleteAttachment, isPending: deleteAttachmentPending } =
useDeleteAttachment();
const handleDeleteAttachment = (id: number) => {
deleteAttachment(
{ id },
{
onSuccess: () => {
emit("delete", id);
},
}
);
};
</script>
<template>
<li class="w-full flex items-center">
<div class="w-2/12 flex justify-start">
<p class="text-sm text-black">{{ (size / 1024).toFixed(2) }}KB</p>
</div>
<div class="flex justify-end w-9/12">
<p class="text-sm text-black">
{{ name }}
</p>
</div>
<div class="w-1/12 ps-1">
<button class="cursor-pointer" @click="handleDeleteAttachment(id)">
<Icon
:name="
deleteAttachmentPending
? 'svg-spinners:ring-resize'
: 'ci:close'
"
:class="
deleteAttachmentPending
? 'text-black/50'
: '**:stroke-red-500'
"
class="pt-1"
size="28"
/>
</button>
</div>
</li>
</template>
<style scoped></style>
@@ -5,17 +5,17 @@ import { API_ENDPOINTS } from "~/constants";
// types // types
export type CreateOrUpdateAddressRequest = Omit<Address, "is_main">; export type CreateOrUpdateAddressResponse = Omit<Address, "is_main">;
const useCreateOrUpdateAddress = (update: ComputedRef<Boolean>) => { const useCreateOrUpdateAddress = (update: ComputedRef<Boolean>) => {
// state // state
const { $axios: axios } = useNuxtApp(); const { $axios: axios } = useNuxtApp();
// method // methods
const handleCreateOrUpdateAddress = async ( const handleCreateOrUpdateAddress = async (
addressData: CreateOrUpdateAddressRequest addressData: CreateOrUpdateAddressResponse
) => { ) => {
const { data } = await axios[update.value ? "put" : "post"]( const { data } = await axios[update.value ? "put" : "post"](
update.value update.value
@@ -30,7 +30,7 @@ const useCreateOrUpdateAddress = (update: ComputedRef<Boolean>) => {
}; };
return useMutation({ return useMutation({
mutationFn: (addressData: CreateOrUpdateAddressRequest) => mutationFn: (addressData: CreateOrUpdateAddressResponse) =>
handleCreateOrUpdateAddress(addressData), handleCreateOrUpdateAddress(addressData),
}); });
}; };
@@ -14,7 +14,7 @@ const useDeleteAddress = () => {
const { $axios: axios } = useNuxtApp(); const { $axios: axios } = useNuxtApp();
// method // methods
const handleDeleteAddress = async (id: number) => { const handleDeleteAddress = async (id: number) => {
const { data } = await axios.delete( const { data } = await axios.delete(
@@ -20,7 +20,7 @@ const useUpdateAccount = () => {
const { $axios: axios } = useNuxtApp(); const { $axios: axios } = useNuxtApp();
// method // methods
const handleUpdateAccount = async (params: UpdateAccountRequest) => { const handleUpdateAccount = async (params: UpdateAccountRequest) => {
const { data } = await axios.patch( const { data } = await axios.patch(
+9 -4
View File
@@ -1,11 +1,10 @@
export const useAuth = () => { export const useAuth = () => {
// state // state
const token = useCookie("token"); const token = useCookie("token");
const refreshToken = useCookie("refresh-token"); const refreshToken = useCookie("refresh-token");
// method // methods
const updateToken = (newToken: string) => { const updateToken = (newToken: string) => {
token.value = newToken; token.value = newToken;
@@ -25,6 +24,12 @@ export const useAuth = () => {
const isLoggedIn = computed(() => !!token.value); const isLoggedIn = computed(() => !!token.value);
return { token, refreshToken, updateRefreshToken, updateToken, logout, isLoggedIn }; return {
token,
refreshToken,
updateRefreshToken,
updateToken,
logout,
isLoggedIn,
};
}; };
@@ -6,17 +6,15 @@ import { API_ENDPOINTS } from "~/constants";
// types // types
export type RefreshAuthRequest = { export type RefreshAuthRequest = {
refresh: string, refresh: string;
}; };
export type RefreshAuthResponse = { export type RefreshAuthResponse = {
access: string, access: string;
refresh: string, refresh: string;
}; };
const useRefreshAuth = () => { const useRefreshAuth = () => {
// state // state
const { $axios: axios } = useNuxtApp(); const { $axios: axios } = useNuxtApp();
@@ -24,12 +22,16 @@ const useRefreshAuth = () => {
// methods // methods
const handleRefreshAuth = async (variables: RefreshAuthRequest) => { const handleRefreshAuth = async (variables: RefreshAuthRequest) => {
const { data } = await axios.post<RefreshAuthResponse>(`${API_ENDPOINTS.auth.refresh}/`, variables); const { data } = await axios.post<RefreshAuthResponse>(
`${API_ENDPOINTS.auth.refresh}`,
variables
);
return data; return data;
}; };
return useMutation({ return useMutation({
mutationFn: (variables: RefreshAuthRequest) => handleRefreshAuth(variables) mutationFn: (variables: RefreshAuthRequest) =>
handleRefreshAuth(variables),
}); });
}; };
+7 -6
View File
@@ -11,13 +11,11 @@ export type SignInRequest = {
}; };
export type SignInResponse = { export type SignInResponse = {
access: string, access: string;
refresh: string, refresh: string;
}; };
const useSignIn = () => { const useSignIn = () => {
// state // state
const { $axios: axios } = useNuxtApp(); const { $axios: axios } = useNuxtApp();
@@ -25,12 +23,15 @@ const useSignIn = () => {
// methods // methods
const handleSignIn = async (variables: SignInRequest) => { const handleSignIn = async (variables: SignInRequest) => {
const { data } = await axios.post<SignInResponse>(`${API_ENDPOINTS.auth.signin}/`, variables); const { data } = await axios.post<SignInResponse>(
`${API_ENDPOINTS.auth.signin}`,
variables
);
return data; return data;
}; };
return useMutation({ return useMutation({
mutationFn: (variables: SignInRequest) => handleSignIn(variables) mutationFn: (variables: SignInRequest) => handleSignIn(variables),
}); });
}; };
@@ -11,29 +11,37 @@ export type CreateChatMessageRequest = {
new_message: string; new_message: string;
}; };
export type CreateChatMessageResponse = Chat[] export type CreateChatMessageResponse = Chat[];
const useCreateChatMessage = (queryClient: QueryClient) => { const useCreateChatMessage = (queryClient: QueryClient) => {
// state // state
const { $axios: axios } = useNuxtApp(); const { $axios: axios } = useNuxtApp();
// method // methods
const handleCreateChatMessage = async (variables: CreateChatMessageRequest) => { const handleCreateChatMessage = async (
variables: CreateChatMessageRequest
const { data } = await axios.post<CreateChatMessageResponse>(`${API_ENDPOINTS.chat.new_message}/${variables.productId}`, variables); ) => {
const { data } = await axios.post<CreateChatMessageResponse>(
`${API_ENDPOINTS.chat.new_message}/${variables.productId}`,
variables
);
return data; return data;
}; };
return useMutation({ return useMutation({
mutationKey: [MUTATION_KEYS.create_chat], mutationKey: [MUTATION_KEYS.create_chat],
mutationFn: (variables: CreateChatMessageRequest) => handleCreateChatMessage(variables), mutationFn: (variables: CreateChatMessageRequest) =>
handleCreateChatMessage(variables),
onMutate: (newMessage) => { onMutate: (newMessage) => {
const prevData = queryClient.getQueriesData({ queryKey: [QUERY_KEYS.chat] }); const prevData = queryClient.getQueriesData({
queryKey: [QUERY_KEYS.chat],
});
queryClient.setQueryData<InfiniteData<ApiPaginated<Chat>>>([QUERY_KEYS.chat], (oldData) => { queryClient.setQueryData<InfiniteData<ApiPaginated<Chat>>>(
[QUERY_KEYS.chat],
(oldData) => {
const lastPage = oldData!.pages[oldData!.pages.length - 1]; const lastPage = oldData!.pages[oldData!.pages.length - 1];
return { return {
@@ -46,30 +54,32 @@ const useCreateChatMessage = (queryClient: QueryClient) => {
{ {
id: Date.now(), id: Date.now(),
content: newMessage.new_message, content: newMessage.new_message,
sender: "user" sender: "user",
}
]
}, },
...oldData!.pages ],
},
...oldData!.pages,
], ],
pageParams: [ pageParams: [
...oldData!.pageParams, ...oldData!.pageParams,
{ {
limit: 10, limit: 10,
offset: 0 offset: 0,
} },
] ],
}; };
}); }
);
return { prevData: prevData ? prevData[0][1] : undefined }; return { prevData: prevData ? prevData[0][1] : undefined };
}, },
onSuccess: (response) => { onSuccess: (response) => {
queryClient.setQueryData<InfiniteData<ApiPaginated<Chat>>>(
queryClient.setQueryData<InfiniteData<ApiPaginated<Chat>>>([QUERY_KEYS.chat], (oldData) => { [QUERY_KEYS.chat],
(oldData) => {
if (oldData) { if (oldData) {
const lastPage = oldData!.pages[oldData!.pages.length - 1]; const lastPage =
oldData!.pages[oldData!.pages.length - 1];
return { return {
pages: [ pages: [
@@ -79,36 +89,33 @@ const useCreateChatMessage = (queryClient: QueryClient) => {
previous: lastPage.previous, previous: lastPage.previous,
results: { results: {
...response[0], ...response[0],
id: Date.now() id: Date.now(),
}
}, },
...oldData!.pages },
...oldData!.pages,
], ],
pageParams: [ pageParams: [
...oldData!.pageParams, ...oldData!.pageParams,
{ {
limit: 10, limit: 10,
offset: 0 offset: 0,
} },
] ],
}; };
} }
return oldData; return oldData;
}); }
);
}, },
onError: (err, newMessage, context) => { onError: (err, newMessage, context) => {
if (context) { if (context) {
queryClient.setQueryData( queryClient.setQueryData([QUERY_KEYS.chat], context.prevData);
[QUERY_KEYS.chat],
context.prevData
);
} }
}, },
onSettled: (newMessage) => { onSettled: (newMessage) => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.chat] }); queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.chat] });
} },
}); });
}; };
+23 -20
View File
@@ -8,34 +8,36 @@ import { useAuth } from "~/composables/api/auth/useAuth";
export type GetBranchResponse = ApiPaginated<Chat>; export type GetBranchResponse = ApiPaginated<Chat>;
const useGetBranch = ( const useGetBranch = (productId: string | number, enabled: Ref<boolean>) => {
productId: string | number,
enabled: Ref<boolean>
) => {
// state // state
const { $axios: axios } = useNuxtApp(); const { $axios: axios } = useNuxtApp();
const { isLoggedIn } = useAuth(); const { isLoggedIn } = useAuth();
// method // methods
const handleGetChat = async ({ productId, limit, offset }: { const handleGetChat = async ({
productId: number | string, productId,
limit: number, limit,
offset: number offset,
}: {
productId: number | string;
limit: number;
offset: number;
}) => { }) => {
const { data } = await axios.get<GetBranchResponse>(
const { data } = await axios.get<GetBranchResponse>(`${API_ENDPOINTS.chat.messages}/${productId}`, { `${API_ENDPOINTS.chat.messages}/${productId}`,
{
params: { params: {
offset, offset,
limit limit,
}, },
headers: { headers: {
Authorization: `Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoyMTY3ODE2OTAwLCJpYXQiOjE3MzU4MTY5MDAsImp0aSI6ImQwN2E2Y2Y2NzgwZjRlNTE5NWIzOGQxMTAzYzU4NDQ3IiwidXNlcl9pZCI6NX0.slwd7ZSV7nUXEuDTYwwHUOo9ekCefwEEL4kVv2vSTFo` Authorization: `Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoyMTY3ODE2OTAwLCJpYXQiOjE3MzU4MTY5MDAsImp0aSI6ImQwN2E2Y2Y2NzgwZjRlNTE5NWIzOGQxMTAzYzU4NDQ3IiwidXNlcl9pZCI6NX0.slwd7ZSV7nUXEuDTYwwHUOo9ekCefwEEL4kVv2vSTFo`,
},
} }
}); );
return data; return data;
}; };
@@ -44,21 +46,22 @@ const useGetBranch = (
queryKey: [QUERY_KEYS.chat], queryKey: [QUERY_KEYS.chat],
initialPageParam: { initialPageParam: {
limit: 10, limit: 10,
offset: 0 offset: 0,
}, },
queryFn: ({ pageParam }) => handleGetChat({ queryFn: ({ pageParam }) =>
handleGetChat({
limit: pageParam.limit, limit: pageParam.limit,
offset: pageParam.offset, offset: pageParam.offset,
productId: productId productId: productId,
}), }),
getNextPageParam: (lastPage, pages) => { getNextPageParam: (lastPage, pages) => {
if (!lastPage.next) return undefined; if (!lastPage.next) return undefined;
return { return {
limit: 10, limit: 10,
offset: pages.length * 10 offset: pages.length * 10,
}; };
} },
}); });
}; };
@@ -6,24 +6,27 @@ import { API_ENDPOINTS } from "~/constants";
// types // types
export type CreateCommentRequest = { export type CreateCommentRequest = {
content: string content: string;
}; };
const useCreateComment = (id: number | string | undefined) => { const useCreateComment = (id: number | string | undefined) => {
// state // state
const { $axios: axios } = useNuxtApp(); const { $axios: axios } = useNuxtApp();
// method // methods
const handleCreateComment = async (variables: CreateCommentRequest) => { const handleCreateComment = async (variables: CreateCommentRequest) => {
const { data } = await axios.post(`${API_ENDPOINTS.product.create_comment}/${id}`, variables); const { data } = await axios.post(
`${API_ENDPOINTS.product.create_comment}/${id}`,
variables
);
return data; return data;
}; };
return useMutation({ return useMutation({
mutationFn: (variables: CreateCommentRequest) => handleCreateComment(variables) mutationFn: (variables: CreateCommentRequest) =>
handleCreateComment(variables),
}); });
}; };
@@ -0,0 +1,50 @@
// imports
import { useMutation } from "@tanstack/vue-query";
import { API_ENDPOINTS } from "~/constants";
// types
export type CreateTicketRequest = {
ticket_category: string | undefined;
order: number | undefined;
subject: string;
content: string;
attachments: {
id: number;
file_link: string;
date: string;
size: number;
name: string;
}[];
};
const useCreateTicket = () => {
// state
const { $axios: axios } = useNuxtApp();
// methods
const handleCreateTicket = async (params: CreateTicketRequest) => {
const { data } = await axios.post(
API_ENDPOINTS.account.address.update,
{
...params,
},
{
headers: {
"Content-Type": "multipart/form-data",
},
}
);
return data;
};
return useMutation({
mutationFn: (ticketData: CreateTicketRequest) =>
handleCreateTicket(ticketData),
});
};
export default useCreateTicket;
@@ -0,0 +1,36 @@
// imports
import { useMutation } from "@tanstack/vue-query";
import { API_ENDPOINTS } from "~/constants";
// types
export type DeleteAttachmentRequest = {
id: number | string;
};
// methods
export const handleDeleteAttachment = async ({
id,
}: DeleteAttachmentRequest) => {
// state
const { $axios: axios } = useNuxtApp();
const { data } = await axios.delete(
`${API_ENDPOINTS.tickets.delete_attachment}/${id}`
);
return data;
};
// composable
const useDeleteAttachment = () => {
return useMutation({
mutationFn: (data: DeleteAttachmentRequest) =>
handleDeleteAttachment({ ...data }),
});
};
export default useDeleteAttachment;
@@ -0,0 +1,44 @@
// imports
import { useQuery } from "@tanstack/vue-query";
import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
// types
export type GetAllTicketsResponse = ApiPaginated<Ticket>;
export type GetAllTicketsFilters = {
sort: string | undefined;
filter: string | undefined;
page: string | string[];
};
const useGetAllTickets = (params: Ref<GetAllTicketsFilters>) => {
// state
const { $axios: axios } = useNuxtApp();
// methods
const handleGetAllTickets = async (params: GetAllTicketsFilters) => {
const { data } = await axios.get<GetAllTicketsResponse>(
API_ENDPOINTS.tickets.get_all,
{
params: {
sort: params.sort,
filter: params.filter,
offset: Number(params.page) * 10 - 10,
limit: 10,
},
}
);
return data;
};
return useQuery({
queryKey: [QUERY_KEYS.tickets, params],
queryFn: () => handleGetAllTickets(params.value),
});
};
export default useGetAllTickets;
@@ -0,0 +1,52 @@
// imports
import { useMutation } from "@tanstack/vue-query";
import { API_ENDPOINTS } from "~/constants";
// types
export type UploadAttachmentRequest = {
file: File;
};
export type UploadAttachmentResponse = {
id: number;
file_link: string;
date: string;
size: number;
name: string;
};
// methods
export const handleUploadAttachment = async ({
file,
}: UploadAttachmentRequest) => {
// state
const { $axios: axios } = useNuxtApp();
const { data } = await axios.post<UploadAttachmentResponse>(
API_ENDPOINTS.tickets.upload_attachment,
{
file,
},
{
headers: {
"Content-Type": "multipart/form-data",
},
}
);
return data;
};
// composable
const useUploadAttachment = () => {
return useMutation({
mutationFn: (data: UploadAttachmentRequest) =>
handleUploadAttachment({ file: data.file }),
});
};
export default useUploadAttachment;
+7
View File
@@ -34,6 +34,12 @@ export const API_ENDPOINTS = {
get_all: "/products", get_all: "/products",
categories: "/products/categories", categories: "/products/categories",
}, },
tickets: {
get_all: "/tickets",
create: "/tickets/create",
upload_attachment: "/tickets/attachment/create",
delete_attachment: "/tickets/attachment/delete",
},
}; };
export const QUERY_KEYS = { export const QUERY_KEYS = {
@@ -47,6 +53,7 @@ export const QUERY_KEYS = {
account: "account", account: "account",
categories: "categories", categories: "categories",
addresses: "addresses", addresses: "addresses",
tickets: "tickets",
}; };
export const MUTATION_KEYS = { export const MUTATION_KEYS = {
+60 -8
View File
@@ -1,4 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
// imports
import useGetAllTickets, {
type GetAllTicketsFilters,
} from "~/composables/api/tickets/useGetAllTickets";
// meta // meta
definePageMeta({ definePageMeta({
@@ -8,10 +14,12 @@ definePageMeta({
// state // state
const filters = ref({ const params = useUrlSearchParams();
const filters = ref<GetAllTicketsFilters>({
sort: undefined, sort: undefined,
status: undefined, filter: undefined,
search: "", page: params.page ?? 1,
}); });
const tableHeads = ref([ const tableHeads = ref([
@@ -21,6 +29,24 @@ const tableHeads = ref([
"وضعیت", "وضعیت",
"عملیات", "عملیات",
]); ]);
// queries
const { data, isLoading: ticketsIsLoading } = useGetAllTickets(filters);
// computed
const tickets = computed(() => {
return data.value?.results.flat();
});
const hasTickets = computed(() => tickets.value?.length > 0);
const paginationData = computed(() => {
return tickets!.value?.results.map((_, i: number) => {
return { type: "page", value: i };
});
});
</script> </script>
<template> <template>
@@ -30,7 +56,10 @@ const tableHeads = ref([
<div class="w-full flex flex-col gap-5"> <div class="w-full flex flex-col gap-5">
<div class="w-full flex items-center justify-between px-5"> <div class="w-full flex items-center justify-between px-5">
<div class="flex items-center justify-start gap-8"> <div class="flex items-center justify-start gap-8">
<div class="flex items-center justify-start gap-3"> <div
v-if="hasTickets"
class="flex items-center justify-start gap-3"
>
<span class="text-sm">ترتیب بر اساس</span> <span class="text-sm">ترتیب بر اساس</span>
<Select <Select
:options="['جدید ترین', 'قدیمی ترین']" :options="['جدید ترین', 'قدیمی ترین']"
@@ -39,7 +68,10 @@ const tableHeads = ref([
class="w-[5rem]" class="w-[5rem]"
/> />
</div> </div>
<div class="flex items-center justify-start gap-3"> <div
v-if="hasTickets"
class="flex items-center justify-start gap-3"
>
<span class="text-sm">وضعیت پرداخت</span> <span class="text-sm">وضعیت پرداخت</span>
<Select <Select
:options="[ :options="[
@@ -47,7 +79,7 @@ const tableHeads = ref([
'در حال پردازش', 'در حال پردازش',
'لغو شده', 'لغو شده',
]" ]"
v-model="filters.status!" v-model="filters.filter!"
triggerRootClass="!py-2.5" triggerRootClass="!py-2.5"
class="w-[5rem]" class="w-[5rem]"
/> />
@@ -63,7 +95,14 @@ const tableHeads = ref([
</NuxtLink> </NuxtLink>
</div> </div>
<Table> <Placeholder
v-if="!hasTickets && !ticketsIsLoading"
class="!w-full !py-[5rem]"
icon="bi:ticket"
title="تیکتی یافت نشد"
/>
<Table v-else>
<template #thead> <template #thead>
<th <th
v-for="(tableHead, index) in tableHeads" v-for="(tableHead, index) in tableHeads"
@@ -82,9 +121,22 @@ const tableHeads = ref([
</th> </th>
</template> </template>
<template #tbody> <template #tbody>
<TicketsTableRow v-for="i in 4" /> <template v-if="ticketsIsLoading">
<TicketsTableRowLoading v-for="i in 5" />
</template>
<template v-else>
<TicketsTableRow
v-for="(ticket, index) in tickets"
:key="index"
:data="ticket"
/>
</template>
</template> </template>
</Table> </Table>
<div v-if="tickets?.length > 10" class="w-full flex-center py-10">
<Pagination :items="paginationData" :total="data?.count" />
</div>
</div> </div>
</div> </div>
</template> </template>
+106 -11
View File
@@ -1,4 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
// imports
import useCreateTicket, {
type CreateTicketRequest,
} from "~/composables/api/tickets/useCreateTicket";
import useUploadAttachment from "~/composables/api/tickets/useUploadAttachment";
import { useToast } from "~/composables/global/useToast";
import { QUERY_KEYS } from "~/constants";
// meta // meta
definePageMeta({ definePageMeta({
@@ -15,6 +24,12 @@ type TicketCategory = {
// state // state
const router = useRouter();
const { $queryClient: queryClient } = useNuxtApp();
const { addToast } = useToast();
const ticketCategories: TicketCategory[] = [ const ticketCategories: TicketCategory[] = [
{ {
title: "مالی و حسابداری", title: "مالی و حسابداری",
@@ -46,12 +61,76 @@ const ticketCategories: TicketCategory[] = [
}, },
]; ];
const ticketData = ref({ const ticketData = ref<CreateTicketRequest>({
category: undefined, ticket_category: undefined,
order: undefined, order: undefined,
message: "", subject: "",
files: [], content: "",
attachments: [],
}); });
// queries
const { mutateAsync: createTicket, isPending: createTicketIsPending } =
useCreateTicket();
const { mutate: uploadAttachment, isPending: uploadAttachmentIsPending } =
useUploadAttachment();
// methods
const handleUploadAttachment = (file: File) => {
uploadAttachment(
{ file },
{
onSuccess: (data) => {
ticketData.value.attachments.push({ ...data });
},
onError: (error) => {
addToast({
message: error.message
? error.message
: "خطایی در آپلود پیوست رخ داد",
options: {
status: "error",
description: "لطفا مجدد تلاش کنید",
},
});
},
}
);
};
const handleSubmit = () => {
createTicket(
{ ...ticketData.value },
{
onSuccess: () => {
router.push({ name: "profile-tickets" });
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.tickets],
});
addToast({
message: "تیکت شما با موفقیت ثبت شد",
options: {
status: "success",
description:
"پس از بررسی پشتیبانی به شما اطلاع رسانی می شود",
},
});
},
onError: () => {
addToast({
message: "خطایی در ثبت تیکت رخ داد",
options: {
status: "success",
description: "لطفا مجدد تلاش کنید",
},
});
},
}
);
};
</script> </script>
<template> <template>
@@ -75,7 +154,7 @@ const ticketData = ref({
<Select <Select
placeholder="انتخاب کنید" placeholder="انتخاب کنید"
variant="outlined" variant="outlined"
v-model="ticketData.category" v-model="ticketData.ticket_category"
> >
<template #content> <template #content>
<SelectGroup> <SelectGroup>
@@ -133,23 +212,39 @@ const ticketData = ref({
</template> </template>
</Select> </Select>
</DataField> </DataField>
<DataField
id="subject"
:required="true"
label="عنوان تیکت"
class="col-span-full"
>
<Input
v-model="ticketData.subject"
placeholder="عنوان تیکت را اینجا بنویسید ..."
variant="outlined"
/>
</DataField>
<DataField id="message" :required="true" label="متن تیکت"> <DataField id="message" :required="true" label="متن تیکت">
<textarea <textarea
v-model="ticketData.message" v-model="ticketData.content"
class="w-full bg-white border-[1.5px] border-slate-200 rounded-xl text-xs lg:text-sm p-5 text-black h-[10rem] lg:h-[20rem] transition resize-none outline-none focus:!border-black" class="w-full bg-white border-[1.5px] border-slate-200 rounded-xl text-xs lg:text-sm p-5 text-black h-[10rem] lg:h-[20rem] transition resize-none outline-none focus:!border-black"
placeholder="متن تیکت را اینجا بنویسید ..." placeholder="متن تیکت را اینجا بنویسید ..."
/> />
</DataField> </DataField>
<FileInput v-model="ticketData.files" /> <FileInput
v-model="ticketData.attachments"
@change="handleUploadAttachment"
:loading="uploadAttachmentIsPending"
/>
</div> </div>
</ProfileSection> </ProfileSection>
<div class="w-full flex-center py-5"> <div class="w-full flex-center py-5">
<Button class="rounded-full px-20" end-icon="bi:send" size="md"> <Button class="rounded-full px-20" end-icon="bi:send" size="md">
<!-- <Icon <Icon
v-if="createAddressIsPending" v-if="createTicketIsPending"
name="svg-spinners:3-dots-bounce" name="svg-spinners:3-dots-bounce"
/> --> />
<span>ارسال تیکت</span> <span v-else>ارسال تیکت</span>
</Button> </Button>
</div> </div>
</div> </div>
+14 -9
View File
@@ -8,7 +8,9 @@ export default defineNuxtPlugin(() => {
const { token, logout } = useAuth(); const { token, logout } = useAuth();
const axios = axiosOriginal.create({ const axios = axiosOriginal.create({
baseURL: config.public.API_BASE_URL timeout: 30000,
timeoutErrorMessage: "فرآیند بیش از حد انتظار طول کشید",
baseURL: config.public.API_BASE_URL,
}); });
axios.interceptors.request.use((config) => { axios.interceptors.request.use((config) => {
@@ -16,16 +18,19 @@ export default defineNuxtPlugin(() => {
!config.url?.includes(API_ENDPOINTS.auth.signin) && !config.url?.includes(API_ENDPOINTS.auth.signin) &&
!config.url?.includes(API_ENDPOINTS.account.send_otp) !config.url?.includes(API_ENDPOINTS.account.send_otp)
) { ) {
config.headers.Authorization = token.value ? `Bearer ${token.value}` : undefined; config.headers.Authorization = token.value
? `Bearer ${token.value}`
: undefined;
} }
return config; return config;
}); });
axios.interceptors.response.use((response) => { axios.interceptors.response.use(
(response) => {
return response; return response;
}, async function(error) { },
async function (error) {
await Logger.axiosErrorLog(error); await Logger.axiosErrorLog(error);
// if (error.status === 401) { // if (error.status === 401) {
@@ -33,12 +38,12 @@ export default defineNuxtPlugin(() => {
// } // }
return Promise.reject(error); return Promise.reject(error);
}); }
);
return { return {
provide: { provide: {
axios axios,
} },
}; };
}); });
+9
View File
@@ -165,4 +165,13 @@ declare global {
iconClass?: string; iconClass?: string;
onClick?: () => void; onClick?: () => void;
}; };
type Ticket = {
id: number;
subject: string;
ticket_category: string;
status: string;
created_at: string;
updated_at: string;
};
} }