This commit is contained in:
2026-04-03 13:29:32 +03:30
parent e6e62fa46b
commit 4c51f9b5cc
20 changed files with 1406 additions and 210 deletions

1
.env.example Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=https://parsshop-back.mugit.ir/api

View File

@@ -5,11 +5,11 @@
<div class="space-y-2"> <div class="space-y-2">
<div class="inline-flex items-center gap-2 rounded-full border border-white-light bg-white-light/40 px-3 py-1 text-xs font-semibold text-primary dark:border-[#1b2e4b] dark:bg-[#060818]"> <div class="inline-flex items-center gap-2 rounded-full border border-white-light bg-white-light/40 px-3 py-1 text-xs font-semibold text-primary dark:border-[#1b2e4b] dark:bg-[#060818]">
<span class="h-2 w-2 rounded-full bg-primary"></span> <span class="h-2 w-2 rounded-full bg-primary"></span>
ایجاد محصول {{ pageBadgeLabel }}
</div> </div>
<h1 class="text-3xl font-bold tracking-tight text-black dark:text-white">افزودن محصول جدید</h1> <h1 class="text-3xl font-bold tracking-tight text-black dark:text-white">{{ pageTitle }}</h1>
<p class="max-w-3xl text-sm leading-7 text-white-dark"> <p class="max-w-3xl text-sm leading-7 text-white-dark">
فرم به چند باکس مشخص تقسیم شده تا اطلاعات پایه، محتوا، سئو، رسانه و ویژگیها از هم جدا باشند و صفحه شلوغ نشود. {{ pageDescription }}
</p> </p>
</div> </div>
@@ -17,7 +17,7 @@
<router-link to="/admin/products" class="btn btn-outline-secondary">بازگشت به لیست</router-link> <router-link to="/admin/products" class="btn btn-outline-secondary">بازگشت به لیست</router-link>
<button type="submit" form="create-product-form" class="btn btn-primary" :disabled="isSubmitting || isBootLoading"> <button type="submit" form="create-product-form" class="btn btn-primary" :disabled="isSubmitting || isBootLoading">
<span v-if="isSubmitting">در حال ذخیره...</span> <span v-if="isSubmitting">در حال ذخیره...</span>
<span v-else>ذخیره محصول</span> <span v-else>{{ props.mode === 'edit' ? 'ذخیره تغییرات' : 'ذخیره محصول' }}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -121,52 +121,90 @@
</div> </div>
</div> </div>
<div v-else-if="baseInfoTab === 'categories'" class="grid grid-cols-1 gap-5 xl:grid-cols-[220px_minmax(0,1fr)_260px]"> <div v-else-if="baseInfoTab === 'categories'" class="grid grid-cols-1 gap-5 xl:grid-cols-[240px_minmax(0,1fr)_280px]">
<div> <div class="rounded-[24px] border border-primary/15 bg-gradient-to-br from-primary/10 via-white to-white px-5 py-5 shadow-sm dark:border-primary/20 dark:from-primary/10 dark:via-[#0e1726] dark:to-[#0b1321]">
<label for="product-type">نوع محصول</label> <label for="product-type" class="text-sm font-semibold text-black dark:text-white">نوع محصول</label>
<select id="product-type" v-model="form.type" class="form-select"> <p class="mt-1 text-xs leading-6 text-white-dark">با انتخاب نوع، فقط دستهبندیها و برندهای مرتبط نمایش داده میشوند.</p>
<select id="product-type" v-model="form.type" class="form-select mt-4 rounded-[16px] border-white bg-white/90 shadow-sm dark:border-[#1b2e4b] dark:bg-[#0b1321]">
<option v-for="item in productTypeOptions" :key="item.value" :value="item.value">{{ item.label }}</option> <option v-for="item in productTypeOptions" :key="item.value" :value="item.value">{{ item.label }}</option>
</select> </select>
<div class="mb-2 flex items-center justify-between gap-3"> <div class="mt-4 rounded-[18px] border border-primary/15 bg-white/80 px-4 py-3 text-sm shadow-sm dark:border-primary/20 dark:bg-[#0f1b30]/80">
<span class="text-xs text-white-dark">ÙÙØ§ÛŒ درختی</span> <div class="text-xs text-white-dark">نوع انتخابشده</div>
<button v-if="form.categoryId" type="button" class="text-xs text-danger hover:underline" @click="form.categoryId = ''">پاک Ú©Ø±Ø¯Ù Ø§ÙØªØ®Ø§Ø¨</button> <div class="mt-1 font-semibold text-black dark:text-white">{{ selectedProductTypeLabel }}</div>
<div class="mt-2 text-[11px] leading-5 text-white-dark">{{ filteredCategories.length }} دستهبندی و {{ filteredBrands.length }} برند برای این نوع در دسترس است.</div>
</div> </div>
<input id="product-category-search" v-model.trim="categorySearch" type="text" class="form-input" placeholder="جستجو در دسته‌بندی‌ها..." /> </div>
<div class="mt-3 max-h-[340px] overflow-y-auto rounded-[22px] border border-white-light px-3 py-3 dark:border-[#1b2e4b]">
<div v-if="visibleCategoryRows.length" class="space-y-1"> <div class="overflow-hidden rounded-[24px] border border-white-light/80 bg-white shadow-sm dark:border-[#1b2e4b] dark:bg-[#0b1321]">
<div class="flex flex-col gap-4 border-b border-white-light/80 bg-gradient-to-r from-white to-primary/5 px-5 py-5 dark:border-[#1b2e4b] dark:from-[#0b1321] dark:to-primary/10 lg:flex-row lg:items-start lg:justify-between">
<div class="min-w-0">
<label for="product-category-search" class="text-sm font-semibold text-black dark:text-white">دستهبندی محصولات</label>
<p class="mt-1 text-xs leading-6 text-white-dark">از میان ساختار درختی دستهها، مناسبترین گزینه نهایی را برای محصول انتخاب کنید.</p>
</div>
<button
v-if="form.categoryId"
type="button"
class="inline-flex shrink-0 items-center justify-center rounded-full border border-danger/20 bg-danger/5 px-3 py-2 text-xs font-semibold text-danger transition hover:bg-danger/10"
@click="form.categoryId = ''"
>
پاک کردن انتخاب
</button>
</div>
<div class="px-5 py-5">
<input
id="product-category-search"
v-model.trim="categorySearch"
type="text"
class="form-input rounded-[16px] border-white bg-white-light/50 shadow-sm dark:border-[#1b2e4b] dark:bg-[#060818]"
placeholder="جستجو در دسته‌بندی‌ها..."
/>
<div class="mt-4 max-h-[340px] overflow-y-auto rounded-[20px] border border-white-light/80 bg-white-light/20 px-3 py-3 dark:border-[#1b2e4b] dark:bg-[#060818]/70">
<div v-if="visibleCategoryRows.length" class="space-y-2">
<label <label
v-for="row in visibleCategoryRows" v-for="row in visibleCategoryRows"
:key="row.id" :key="row.id"
class="flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2 transition hover:bg-white-light/40 dark:hover:bg-[#060818]" class="group flex cursor-pointer items-center gap-3 rounded-[18px] border border-transparent bg-white/80 px-3 py-3 shadow-sm transition hover:border-primary/20 hover:bg-primary/5 dark:bg-[#0b1321]/80 dark:hover:bg-primary/10"
:class="isCategoryChecked(row.id) ? 'border-primary/30 bg-primary/5 dark:bg-primary/10' : ''"
:style="{ paddingRight: `${0.75 + row.level * 1.25}rem` }" :style="{ paddingRight: `${0.75 + row.level * 1.25}rem` }"
> >
<input type="checkbox" class="form-checkbox outline-primary" :checked="isCategoryChecked(row.id)" @change="toggleCategorySelection(row.id)" /> <input type="checkbox" class="form-checkbox outline-primary" :checked="isCategoryChecked(row.id)" @change="toggleCategorySelection(row.id)" />
<span class="min-w-0 flex-1"> <span class="min-w-0 flex-1">
<span class="block truncate text-sm font-semibold text-black dark:text-white">{{ row.name }}</span> <span class="block truncate text-sm font-semibold text-black dark:text-white">{{ row.name }}</span>
<span class="text-[11px] text-white-dark">{{ row.slug }}</span> <span class="mt-1 block text-[11px] text-white-dark">{{ row.slug }}</span>
</span> </span>
<span class="rounded-full bg-white-light px-2 py-1 text-[10px] font-semibold text-white-dark dark:bg-[#111c33]">سطح {{ row.level + 1 }}</span>
</label> </label>
</div> </div>
<div v-else class="rounded-xl border border-dashed border-white-light px-4 py-6 text-center text-sm text-white-dark dark:border-[#1b2e4b]"> <div v-else class="rounded-[18px] border border-dashed border-white-light px-4 py-8 text-center text-sm text-white-dark dark:border-[#1b2e4b]">
دستÙâŒØ¨Ùدی ÙØ·Ø§Ø¨Ù جستجو پیدا ÙØ´Ø¯. دستهبندی مطابق جستجو پیدا نشد.
</div> </div>
</div> </div>
<p v-if="selectedCategoryLabel" class="mt-2 text-xs text-success">Ø§ÙØªØ®Ø§Ø¨ فعÙÛŒ: {{ selectedCategoryLabel }}</p>
<div class="mt-4 rounded-[18px] border border-success/20 bg-success/5 px-4 py-4 dark:border-success/20">
<div class="text-xs text-white-dark">انتخاب فعلی</div>
<div class="mt-1 text-sm font-semibold text-black dark:text-white">{{ selectedCategoryLabel || 'هنوز دسته‌بندی انتخاب نشده است' }}</div>
<div v-if="selectedCategoryTrail.length" class="mt-2 text-[11px] leading-6 text-white-dark">
مسیر: {{ selectedCategoryTrail.join(' / ') }}
</div> </div>
<div>
<label for="product-category">دستهبندی</label>
<select id="product-category" v-model="form.categoryId" class="hidden">
<option value="">یک دستهبندی انتخاب کنید</option>
<option v-for="category in filteredCategories" :key="category.id" :value="category.id">{{ category.name }}</option>
</select>
</div> </div>
<div> </div>
<label for="product-brand">برند</label> </div>
<select id="product-brand" v-model="form.brandId" class="form-select">
<div class="rounded-[24px] border border-white-light/80 bg-gradient-to-b from-white to-white-light/30 px-5 py-5 shadow-sm dark:border-[#1b2e4b] dark:from-[#0b1321] dark:to-[#09111e]">
<label for="product-brand" class="text-sm font-semibold text-black dark:text-white">برند</label>
<p class="mt-1 text-xs leading-6 text-white-dark">برند محصول را از بین موارد سازگار با نوع انتخابشده مشخص کنید.</p>
<select id="product-brand" v-model="form.brandId" class="form-select mt-4 rounded-[16px] border-white bg-white/90 shadow-sm dark:border-[#1b2e4b] dark:bg-[#060818]">
<option value="">یک برند انتخاب کنید</option> <option value="">یک برند انتخاب کنید</option>
<option v-for="brand in filteredBrands" :key="brand.id" :value="brand.id">{{ brand.name }}</option> <option v-for="brand in filteredBrands" :key="brand.id" :value="brand.id">{{ brand.name }}</option>
</select> </select>
<p v-if="form.brand && !form.brandId" class="mt-2 text-xs text-warning">Fallback brand: {{ form.brand }}</p>
<div class="mt-4 rounded-[18px] border border-white-light bg-white/80 px-4 py-4 text-sm shadow-sm dark:border-[#1b2e4b] dark:bg-[#060818]">
<div class="text-xs text-white-dark">وضعیت برند</div>
<div class="mt-1 font-semibold text-black dark:text-white">{{ selectedBrand?.name || 'برندی انتخاب نشده است' }}</div>
<div v-if="form.brand && !form.brandId" class="mt-2 text-[11px] leading-5 text-warning">مقدار جایگزین فعلی: {{ form.brand }}</div>
</div>
</div> </div>
</div> </div>
@@ -260,7 +298,7 @@
</div> </div>
</AdminFormSection> </AdminFormSection>
<AdminFormSection title="رسانه‌ها" description="برای هر رسانه می‌توانید فایل جدید آپلود کنید یا از Media Library انتخاب کنید."> <AdminFormSection title="رسانه‌ها" description="برای هر رسانه، فایل را فقط از طریق Media Library انتخاب کنید.">
<template #icon> <template #icon>
<IconGallery class="h-5 w-5" /> <IconGallery class="h-5 w-5" />
</template> </template>
@@ -281,17 +319,15 @@
</div> </div>
<div class="mt-4 flex flex-wrap gap-2"> <div class="mt-4 flex flex-wrap gap-2">
<button type="button" class="btn btn-outline-primary btn-sm" @click="openMediaPicker('model')">انتخاب از Media Library</button> <button type="button" class="btn btn-outline-primary btn-sm" @click="openMediaPicker('model')">انتخاب از Media Library</button>
<button type="button" class="btn btn-outline-secondary btn-sm" @click="modelInput?.click()">آپلود فایل جدید</button>
<button v-if="modelDisplayName" type="button" class="btn btn-outline-danger btn-sm" @click="clearModelFile">حذف</button> <button v-if="modelDisplayName" type="button" class="btn btn-outline-danger btn-sm" @click="clearModelFile">حذف</button>
</div> </div>
<input ref="modelInput" type="file" accept=".glb,.gltf,.obj,.usdz,.zip,model/*" class="hidden" @change="onModelSelected" />
</div> </div>
<div class="rounded-[22px] border border-white-light p-4 dark:border-[#1b2e4b] xl:col-span-2"> <div class="rounded-[22px] border border-white-light p-4 dark:border-[#1b2e4b] xl:col-span-2">
<div class="mb-4 flex items-center justify-between gap-3"> <div class="mb-4 flex items-center justify-between gap-3">
<div> <div>
<div class="font-semibold text-black dark:text-white">گالری تصاویر</div> <div class="font-semibold text-black dark:text-white">گالری تصاویر</div>
<div class="text-xs text-white-dark">میتوانید فایلهای آپلودی و آیتمهای Media Library را با هم ترکیب کنید</div> <div class="text-xs text-white-dark">تصاویر گالری فقط از Media Library انتخاب میشوند</div>
</div> </div>
<div class="rounded-full border border-white-light px-3 py-1 text-xs text-white-dark dark:border-[#1b2e4b]"> <div class="rounded-full border border-white-light px-3 py-1 text-xs text-white-dark dark:border-[#1b2e4b]">
{{ galleryAssets.length }} آیتم {{ galleryAssets.length }} آیتم
@@ -314,10 +350,8 @@
<div class="mt-4 flex flex-wrap gap-2"> <div class="mt-4 flex flex-wrap gap-2">
<button type="button" class="btn btn-outline-primary btn-sm" @click="openMediaPicker('gallery')">انتخاب از Media Library</button> <button type="button" class="btn btn-outline-primary btn-sm" @click="openMediaPicker('gallery')">انتخاب از Media Library</button>
<button type="button" class="btn btn-outline-secondary btn-sm" @click="galleryInput?.click()">آپلود چند فایل</button>
<button v-if="galleryAssets.length" type="button" class="btn btn-outline-danger btn-sm" @click="clearGalleryAssets">پاک کردن همه</button> <button v-if="galleryAssets.length" type="button" class="btn btn-outline-danger btn-sm" @click="clearGalleryAssets">پاک کردن همه</button>
</div> </div>
<input ref="galleryInput" type="file" accept="image/*" multiple class="hidden" @change="onGalleryFilesSelected" />
</div> </div>
</div> </div>
</AdminFormSection> </AdminFormSection>
@@ -383,10 +417,8 @@
</div> </div>
<div class="mt-4 flex flex-wrap gap-2"> <div class="mt-4 flex flex-wrap gap-2">
<button type="button" class="btn btn-outline-primary btn-sm" @click="openMediaPicker('main')">انتخاب از Media Library</button> <button type="button" class="btn btn-outline-primary btn-sm" @click="openMediaPicker('main')">انتخاب از Media Library</button>
<button type="button" class="btn btn-outline-secondary btn-sm" @click="mainImageInput?.click()">آپلود فایل جدید</button>
<button v-if="mainImagePreview" type="button" class="btn btn-outline-danger btn-sm" @click="clearMainImage">حذف</button> <button v-if="mainImagePreview" type="button" class="btn btn-outline-danger btn-sm" @click="clearMainImage">حذف</button>
</div> </div>
<input ref="mainImageInput" type="file" accept="image/*" class="hidden" @change="onMainImageSelected" />
</section> </section>
</aside> </aside>
</div> </div>
@@ -536,6 +568,7 @@ import type {
ProductAttributeDataType, ProductAttributeDataType,
ProductAttributeOption, ProductAttributeOption,
ProductFormPayload, ProductFormPayload,
ProductListItem,
ProductMeta, ProductMeta,
ProductStatus, ProductStatus,
ProductType, ProductType,
@@ -546,6 +579,7 @@ import {
filterCategoriesByType, filterCategoriesByType,
formatCurrency, formatCurrency,
generateEnglishSlug, generateEnglishSlug,
mapProductToForm,
productStatusOptions, productStatusOptions,
productTypeOptions, productTypeOptions,
slugify, slugify,
@@ -570,6 +604,17 @@ type CategoryTreeRow = {
children: CategoryTreeRow[] children: CategoryTreeRow[]
} }
const props = withDefaults(
defineProps<{
mode?: 'create' | 'edit'
productId?: string
}>(),
{
mode: 'create',
productId: '',
},
)
const MAX_SLUG_LENGTH = 16 const MAX_SLUG_LENGTH = 16
const SLUG_CHECK_DELAY = 600 const SLUG_CHECK_DELAY = 600
@@ -594,9 +639,10 @@ const createEmptyMeta = (): ProductMeta => ({
shareImageUrl: '', shareImageUrl: '',
}) })
const createEmptyAttributeRow = (): ProductAttributeFormRow => ({ const createEmptyAttributeRow = (displayOrder = 0): ProductAttributeFormRow => ({
id: crypto.randomUUID(), id: crypto.randomUUID(),
attributeId: '', attributeId: '',
displayOrder,
valueText: '', valueText: '',
valueNumber: '', valueNumber: '',
valueBoolean: false, valueBoolean: false,
@@ -605,6 +651,12 @@ const createEmptyAttributeRow = (): ProductAttributeFormRow => ({
overrideUnit: '', overrideUnit: '',
}) })
const normalizeAttributeDisplayOrders = (rows: ProductAttributeFormRow[]) =>
rows.map((row, index) => ({
...row,
displayOrder: index,
}))
const createEmptyReusableAttributeForm = () => ({ const createEmptyReusableAttributeForm = () => ({
name: '', name: '',
slug: '', slug: '',
@@ -642,7 +694,7 @@ const form = reactive({
const categories = ref<Category[]>([]) const categories = ref<Category[]>([])
const brands = ref<Brand[]>([]) const brands = ref<Brand[]>([])
const reusableAttributes = ref<ReusableProductAttribute[]>([]) const reusableAttributes = ref<ReusableProductAttribute[]>([])
const attributeRows = ref<ProductAttributeFormRow[]>([createEmptyAttributeRow()]) const attributeRows = ref<ProductAttributeFormRow[]>([createEmptyAttributeRow(0)])
const tagInput = ref('') const tagInput = ref('')
const validationErrors = ref<string[]>([]) const validationErrors = ref<string[]>([])
const errorMessage = ref('') const errorMessage = ref('')
@@ -661,9 +713,6 @@ const isCreateAttributeModalOpen = ref(false)
const isCreatingAttribute = ref(false) const isCreatingAttribute = ref(false)
const createAttributeForm = reactive(createEmptyReusableAttributeForm()) const createAttributeForm = reactive(createEmptyReusableAttributeForm())
const categorySearch = ref('') const categorySearch = ref('')
const mainImageInput = ref<HTMLInputElement | null>(null)
const galleryInput = ref<HTMLInputElement | null>(null)
const modelInput = ref<HTMLInputElement | null>(null)
const mainImageFile = ref<File | null>(null) const mainImageFile = ref<File | null>(null)
const mainImagePreview = ref('') const mainImagePreview = ref('')
const mainImageObjectUrl = ref('') const mainImageObjectUrl = ref('')
@@ -739,6 +788,22 @@ const checkedCategoryIds = computed(() => {
return ids return ids
}) })
const selectedCategoryLabel = computed(() => categoryLookup.value.get(form.categoryId)?.name || '') const selectedCategoryLabel = computed(() => categoryLookup.value.get(form.categoryId)?.name || '')
const selectedCategoryTrail = computed(() => {
if (!form.categoryId) return [] as string[]
const trail: string[] = []
let currentId = form.categoryId
while (currentId) {
const current = categoryLookup.value.get(currentId)
if (!current) break
trail.unshift(current.name)
currentId = current.parentId
}
return trail
})
const selectedProductTypeLabel = computed(() => productTypeOptions.find((item) => item.value === form.type)?.label || form.type)
const visibleCategoryRows = computed<CategoryTreeRow[]>(() => { const visibleCategoryRows = computed<CategoryTreeRow[]>(() => {
const query = categorySearch.value.trim().toLowerCase() const query = categorySearch.value.trim().toLowerCase()
@@ -772,6 +837,13 @@ const visibleCategoryRows = computed<CategoryTreeRow[]>(() => {
}) })
const filteredBrands = computed(() => filterBrandsByProductType(brands.value, form.type)) const filteredBrands = computed(() => filterBrandsByProductType(brands.value, form.type))
const selectedBrand = computed(() => brands.value.find((brand) => brand.id === form.brandId)) const selectedBrand = computed(() => brands.value.find((brand) => brand.id === form.brandId))
const pageBadgeLabel = computed(() => (props.mode === 'edit' ? 'ویرایش محصول' : 'ایجاد محصول'))
const pageTitle = computed(() => (props.mode === 'edit' ? 'ویرایش محصول' : 'افزودن محصول جدید'))
const pageDescription = computed(() =>
props.mode === 'edit'
? 'مشخصات محصول، رسانه‌ها و ترتیب نمایش ویژگی‌ها را در همین فرم ویرایش کنید.'
: 'فرم در چند بخش مجزا چیده شده تا اطلاعات پایه، محتوا، سئو، رسانه و ویژگی‌ها واضح‌تر ثبت شوند و صفحه شلوغ نشود.',
)
const shareImagePreview = computed(() => form.meta.shareImageUrl || '') const shareImagePreview = computed(() => form.meta.shareImageUrl || '')
const modelDisplayName = computed(() => { const modelDisplayName = computed(() => {
if (modelFile.value) return modelFile.value.name if (modelFile.value) return modelFile.value.name
@@ -795,13 +867,13 @@ const mediaPickerTitle = computed(() => {
const mediaPickerDescription = computed(() => { const mediaPickerDescription = computed(() => {
switch (mediaPickerKind.value) { switch (mediaPickerKind.value) {
case 'gallery': case 'gallery':
return 'می‌توانید چند تصویر را از Media Library انتخاب کنید یا در همان مودال فایل جدید آپلود کنید.' return 'تصاویر گالری را از Media Library انتخاب کنید.'
case 'model': case 'model':
return 'یک فایل مدل سه‌بعدی را انتخاب کنید.' return 'یک فایل مدل سه‌بعدی را انتخاب کنید.'
case 'share': case 'share':
return 'برای share image بهتر است از Media Library استفاده شود.' return 'برای share image بهتر است از Media Library استفاده شود.'
default: default:
return 'یک تصویر را از Media Library انتخاب کنید یا مستقیماً از همان مودال آپلود کنید.' return 'رسانه موردنظر را از Media Library انتخاب کنید.'
} }
}) })
const mediaPickerAllowedSections = computed<MediaSection[]>(() => { const mediaPickerAllowedSections = computed<MediaSection[]>(() => {
@@ -947,44 +1019,6 @@ const mergeGalleryAssets = (items: GalleryAsset[]) => {
galleryAssets.value = Array.from(map.values()) galleryAssets.value = Array.from(map.values())
} }
const onMainImageSelected = (event: Event) => {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
form.existingMainImageUrl = ''
mainImageFile.value = file
resetMainImagePreview()
mainImageObjectUrl.value = URL.createObjectURL(file)
mainImagePreview.value = mainImageObjectUrl.value
input.value = ''
}
const onGalleryFilesSelected = (event: Event) => {
const input = event.target as HTMLInputElement
const files = Array.from(input.files || [])
if (!files.length) return
const nextAssets = files.map<GalleryAsset>((file) => ({
id: crypto.randomUUID(),
name: file.name,
previewUrl: URL.createObjectURL(file),
file,
}))
mergeGalleryAssets(nextAssets)
input.value = ''
}
const onModelSelected = (event: Event) => {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
modelFile.value = file
form.existingThreeDModelUrl = ''
input.value = ''
}
const openMediaPicker = (kind: MediaPickerKind) => { const openMediaPicker = (kind: MediaPickerKind) => {
mediaPickerKind.value = kind mediaPickerKind.value = kind
isMediaPickerOpen.value = true isMediaPickerOpen.value = true
@@ -1047,6 +1081,83 @@ const loadBootData = async () => {
} }
} }
const mapAssignmentsToAttributeRows = (attributes: ProductListItem['attributes']): ProductAttributeFormRow[] => {
if (!Array.isArray(attributes)) return [createEmptyAttributeRow(0)]
const sorted = [...attributes].sort((first, second) => {
const firstOrder = typeof first.displayOrder === 'number' ? first.displayOrder : attributes.indexOf(first)
const secondOrder = typeof second.displayOrder === 'number' ? second.displayOrder : attributes.indexOf(second)
return firstOrder - secondOrder
})
const rows = sorted.map((item, index) => {
const reusableAttribute = item.attributeId ? reusableAttributes.value.find((entry) => entry.id === item.attributeId) : undefined
const dataType = item.dataType || reusableAttribute?.dataType || 'text'
return {
id: crypto.randomUUID(),
attributeId: item.attributeId || '',
displayOrder: typeof item.displayOrder === 'number' ? item.displayOrder : index,
valueText: item.valueText ?? (dataType === 'select' ? String(item.valueText ?? '') : ''),
valueNumber: item.valueNumber === null || item.valueNumber === undefined ? '' : String(item.valueNumber),
valueBoolean: Boolean(item.valueBoolean),
valueJson: dataType === 'json' && item.valueJson !== undefined ? JSON.stringify(item.valueJson, null, 2) : '',
valueMultiText: dataType === 'multiselect' && Array.isArray(item.valueJson) ? item.valueJson.map((value) => String(value)) : [],
overrideUnit: item.overrideUnit || '',
} satisfies ProductAttributeFormRow
})
return normalizeAttributeDisplayOrders(rows.length ? rows : [createEmptyAttributeRow(0)])
}
const loadProduct = async () => {
if (props.mode !== 'edit' || !props.productId) return
const product = await productService.getAdminProduct(props.productId)
const mapped = mapProductToForm(product)
form.sku = mapped.sku
form.title = mapped.title
form.slug = mapped.slug
form.technicalCode = mapped.technicalCode
form.brandId = mapped.brandId || ''
form.brand = mapped.brand || ''
form.basePriceUSD = mapped.basePriceUSD
form.salePriceUSD = mapped.salePriceUSD === '' || mapped.salePriceUSD === undefined ? null : mapped.salePriceUSD
form.stock = mapped.stock
form.type = mapped.type
form.status = mapped.status
form.featured = mapped.featured
form.categoryId = mapped.categoryId || ''
form.tags = [...mapped.tags]
form.meta = {
...createEmptyMeta(),
...mapped.meta,
}
form.existingMainImageUrl = mapped.existingMainImageUrl || ''
form.existingThreeDModelUrl = mapped.existingThreeDModelUrl || ''
mainImageFile.value = null
mainImagePreview.value = mapped.existingMainImageUrl || ''
modelFile.value = null
categorySearch.value = ''
tagInput.value = ''
slugDraft.value = mapped.slug
slugTouched.value = true
slugGeneratedOnce.value = Boolean(mapped.slug)
slugCheckState.value = 'idle'
clearGalleryAssets()
galleryAssets.value = (mapped.existingGalleryUrls || []).map((url, index) => ({
id: `existing-${index}`,
name: url.split('/').pop() || `gallery-${index + 1}`,
previewUrl: url,
existingUrl: url,
}))
attributeRows.value = mapAssignmentsToAttributeRows(product.attributes)
}
const checkSlugAvailability = async (slug: string) => { const checkSlugAvailability = async (slug: string) => {
const normalized = normalizeProductSlug(slug) const normalized = normalizeProductSlug(slug)
if (!normalized) { if (!normalized) {
@@ -1056,7 +1167,10 @@ const checkSlugAvailability = async (slug: string) => {
slugCheckState.value = 'checking' slugCheckState.value = 'checking'
try { try {
const isAvailable = await productService.checkAdminProductSlug(normalized) const isAvailable = await productService.checkAdminProductSlug(
normalized,
props.mode === 'edit' && props.productId ? props.productId : undefined,
)
slugCheckState.value = isAvailable ? 'available' : 'taken' slugCheckState.value = isAvailable ? 'available' : 'taken'
} catch { } catch {
slugCheckState.value = 'idle' slugCheckState.value = 'idle'
@@ -1113,7 +1227,10 @@ const submitCreateAttribute = async () => {
if (emptyRow) { if (emptyRow) {
emptyRow.attributeId = created.id emptyRow.attributeId = created.id
} else { } else {
attributeRows.value.push({ ...createEmptyAttributeRow(), attributeId: created.id }) attributeRows.value = normalizeAttributeDisplayOrders([
...attributeRows.value,
{ ...createEmptyAttributeRow(attributeRows.value.length), attributeId: created.id },
])
} }
closeCreateAttributeModal() closeCreateAttributeModal()
await Swal.fire({ icon: 'success', title: 'موفق', text: 'ویژگی reusable ساخته شد', timer: 1400, showConfirmButton: false }) await Swal.fire({ icon: 'success', title: 'موفق', text: 'ویژگی reusable ساخته شد', timer: 1400, showConfirmButton: false })
@@ -1127,13 +1244,19 @@ const submitCreateAttribute = async () => {
const getReusableAttribute = (attributeId: string) => reusableAttributes.value.find((item) => item.id === attributeId) const getReusableAttribute = (attributeId: string) => reusableAttributes.value.find((item) => item.id === attributeId)
const buildAttributeAssignments = (): ProductAttributeAssignment[] => { const buildAttributeAssignments = (): ProductAttributeAssignment[] => {
return attributeRows.value return normalizeAttributeDisplayOrders(attributeRows.value)
.filter((row) => row.attributeId) .filter((row) => row.attributeId)
.map((row) => { .map((row) => {
const attribute = getReusableAttribute(row.attributeId) const attribute = getReusableAttribute(row.attributeId)
const base: ProductAttributeAssignment = { const base: ProductAttributeAssignment = {
attributeId: row.attributeId, attributeId: row.attributeId,
displayOrder: row.displayOrder,
overrideUnit: row.overrideUnit.trim() || undefined, overrideUnit: row.overrideUnit.trim() || undefined,
name: attribute?.name,
slug: attribute?.slug,
dataType: attribute?.dataType,
isVisible: attribute?.isVisible,
isFilterable: attribute?.isFilterable,
} }
if (!attribute) return base if (!attribute) return base
@@ -1194,13 +1317,13 @@ const validateAttributeRows = () => {
const validateForm = () => { const validateForm = () => {
const errors: string[] = [] const errors: string[] = []
if (!form.sku.trim()) errors.push('SKU الزامی است') if (props.mode === 'edit' && !form.sku.trim()) errors.push('SKU الزامی است')
if (!form.title.trim()) errors.push('عنوان محصول الزامی است') if (!form.title.trim()) errors.push('عنوان محصول الزامی است')
if (!form.slug.trim()) errors.push('Slug الزامی است') if (!form.slug.trim()) errors.push('Slug الزامی است')
if (form.slug.trim().length > MAX_SLUG_LENGTH) errors.push('Slug نمی‌تواند بیشتر از 16 کاراکتر باشد') if (form.slug.trim().length > MAX_SLUG_LENGTH) errors.push('Slug نمی‌تواند بیشتر از 16 کاراکتر باشد')
if (slugCheckState.value === 'taken') errors.push('این اسلاگ قبلاً ثبت شده است') if (slugCheckState.value === 'taken') errors.push('این اسلاگ قبلاً ثبت شده است')
if (!form.technicalCode.trim()) errors.push('Technical code الزامی است') if (props.mode === 'edit' && !form.technicalCode.trim()) errors.push('Technical code الزامی است')
if (!form.brandId && !form.brand.trim()) errors.push('برند الزامی است') if (props.mode === 'edit' && !form.brandId && !form.brand.trim()) errors.push('برند الزامی است')
if (!form.categoryId) errors.push('دسته‌بندی محصول الزامی است') if (!form.categoryId) errors.push('دسته‌بندی محصول الزامی است')
if (Number(form.basePriceUSD) < 0) errors.push('قیمت اصلی نمی‌تواند منفی باشد') if (Number(form.basePriceUSD) < 0) errors.push('قیمت اصلی نمی‌تواند منفی باشد')
if (form.salePriceUSD !== null && Number(form.salePriceUSD) < 0) errors.push('قیمت فروش نمی‌تواند منفی باشد') if (form.salePriceUSD !== null && Number(form.salePriceUSD) < 0) errors.push('قیمت فروش نمی‌تواند منفی باشد')
@@ -1236,7 +1359,7 @@ const resetForm = () => {
slugGeneratedOnce.value = false slugGeneratedOnce.value = false
slugCheckState.value = 'idle' slugCheckState.value = 'idle'
tagInput.value = '' tagInput.value = ''
attributeRows.value = [createEmptyAttributeRow()] attributeRows.value = [createEmptyAttributeRow(0)]
validationErrors.value = [] validationErrors.value = []
errorMessage.value = '' errorMessage.value = ''
slugTouched.value = false slugTouched.value = false
@@ -1251,8 +1374,6 @@ const submitForm = async () => {
sku: form.sku.trim(), sku: form.sku.trim(),
title: form.title.trim(), title: form.title.trim(),
slug: normalizeProductSlug(form.slug.trim()), slug: normalizeProductSlug(form.slug.trim()),
summary: form.meta.shortDescription.trim(),
description: form.meta.description.trim(),
meta: { meta: {
...form.meta, ...form.meta,
shortDescription: form.meta.shortDescription.trim(), shortDescription: form.meta.shortDescription.trim(),
@@ -1272,7 +1393,8 @@ const submitForm = async () => {
featured: form.featured, featured: form.featured,
type: form.type, type: form.type,
status: form.status, status: form.status,
categoryId: form.categoryId || undefined, primaryCategoryId: form.categoryId || undefined,
categoryIds: form.categoryId ? [form.categoryId] : [],
tags: [...form.tags], tags: [...form.tags],
attributes: buildAttributeAssignments(), attributes: buildAttributeAssignments(),
existingMainImageUrl: form.existingMainImageUrl || undefined, existingMainImageUrl: form.existingMainImageUrl || undefined,
@@ -1285,7 +1407,10 @@ const submitForm = async () => {
isSubmitting.value = true isSubmitting.value = true
try { try {
const response = await productService.createAdminProduct(payload) const response =
props.mode === 'edit' && props.productId
? await productService.updateAdminProduct(props.productId, payload)
: await productService.createAdminProduct(payload)
await Swal.fire({ icon: 'success', title: 'محصول ایجاد شد', text: 'محصول با موفقیت ذخیره شد', timer: 1500, showConfirmButton: false }) await Swal.fire({ icon: 'success', title: 'محصول ایجاد شد', text: 'محصول با موفقیت ذخیره شد', timer: 1500, showConfirmButton: false })
router.push(`/admin/products/${response.id}`) router.push(`/admin/products/${response.id}`)
} catch (error) { } catch (error) {
@@ -1349,6 +1474,13 @@ watch(
onMounted(async () => { onMounted(async () => {
await loadBootData() await loadBootData()
if (!errorMessage.value) {
try {
await loadProduct()
} catch (error) {
errorMessage.value = extractApiErrorMessage(error, 'بارگذاری اطلاعات محصول انجام نشد')
}
}
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {

View File

@@ -6,6 +6,10 @@
<span class="text-xs text-white-dark">ویژگیها از API خوانده میشوند و هر ردیف فقط یک ویژگی را مدیریت میکند.</span> <span class="text-xs text-white-dark">ویژگیها از API خوانده میشوند و هر ردیف فقط یک ویژگی را مدیریت میکند.</span>
</div> </div>
<div class="rounded-2xl border border-primary/15 bg-primary/5 px-4 py-3 text-xs leading-6 text-primary dark:border-primary/20">
دو ویژگی اول، در لیست محصولات سایت نمایش داده میشوند. برای تعیین ترتیب نمایش، ردیفها را با دکمههای بالا و پایین جابهجا کنید.
</div>
<div v-if="isLoading" class="grid min-h-[180px] place-content-center rounded-2xl border border-dashed border-white-light dark:border-[#1b2e4b]"> <div v-if="isLoading" class="grid min-h-[180px] place-content-center rounded-2xl border border-dashed border-white-light dark:border-[#1b2e4b]">
<span class="inline-flex h-8 w-8 animate-spin rounded-full border-4 border-primary border-l-transparent"></span> <span class="inline-flex h-8 w-8 animate-spin rounded-full border-4 border-primary border-l-transparent"></span>
</div> </div>
@@ -18,14 +22,42 @@
<div <div
v-for="(row, index) in rows" v-for="(row, index) in rows"
:key="row.id" :key="row.id"
class="rounded-2xl border border-white-light/80 bg-white-light/20 p-4 dark:border-[#1b2e4b] dark:bg-[#060818]" class="overflow-hidden rounded-2xl border border-white-light/80 bg-white-light/20 dark:border-[#1b2e4b] dark:bg-[#060818]"
> >
<div class="mb-4 flex items-center justify-between gap-3"> <button
<div> type="button"
<div class="text-sm font-semibold text-black dark:text-white">ویژگی {{ index + 1 }}</div> class="flex w-full items-start justify-between gap-3 px-4 py-4 text-right transition hover:bg-white/40 dark:hover:bg-white/5"
<div class="text-xs text-white-dark">{{ getAttribute(row.attributeId)?.slug || 'ویژگی انتخاب نشده' }}</div> @click="toggleRow(row.id)"
>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<div class="min-h-[1.25rem] text-sm font-semibold text-black dark:text-white">{{ getAttribute(row.attributeId)?.name || '' }}</div>
<span class="badge bg-dark/10 text-dark dark:bg-white/10 dark:text-white">ترتیب {{ row.displayOrder }}</span>
<span v-if="index < 2" class="badge bg-success/15 text-success">نمایش در لیست</span>
</div>
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs text-white-dark">
<span v-if="getAttribute(row.attributeId)?.name" class="truncate">{{ getAttribute(row.attributeId)?.name }}</span>
<span v-if="summaryUnit(row)">واحد: {{ summaryUnit(row) }}</span>
<span v-if="summaryValue(row)">مقدار: {{ summaryValue(row) }}</span>
</div>
</div>
<IconCaretDown class="mt-1 h-4 w-4 shrink-0 transition" :class="isRowOpen(row.id) ? 'rotate-180 text-primary' : 'text-white-dark'" />
</button>
<div v-if="isRowOpen(row.id)" class="border-t border-white-light/80 px-4 py-4 dark:border-[#1b2e4b]">
<div class="mb-4 flex items-center justify-between gap-3">
<div class="text-xs text-white-dark">{{ getAttribute(row.attributeId)?.slug || '' }}</div>
<div class="flex flex-wrap items-center gap-2">
<button type="button" class="btn btn-outline-secondary btn-sm !px-2.5 text-[11px]" :disabled="index === 0" @click.stop="moveRow(index, -1)"></button>
<button type="button" class="btn btn-outline-secondary btn-sm !px-2.5 text-[11px]" :disabled="index === rows.length - 1" @click.stop="moveRow(index, 1)"></button>
<button
type="button"
class="grid h-8 w-8 place-content-center rounded-lg border border-danger/20 text-danger transition hover:bg-danger/10"
@click.stop="removeRow(index)"
>
<IconTrash class="h-3.5 w-3.5" />
</button>
</div> </div>
<button type="button" class="btn btn-outline-danger btn-sm" @click="removeRow(index)">حذف</button>
</div> </div>
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_180px]"> <div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_180px]">
@@ -110,9 +142,13 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue'
import IconCaretDown from '@/components/icon/icon-caret-down.vue'
import IconTrash from '@/components/icon/icon-trash.vue'
import type { ReusableProductAttribute } from '@/types/product' import type { ReusableProductAttribute } from '@/types/product'
import type { ProductAttributeFormRow } from '@/types/admin-product-create' import type { ProductAttributeFormRow } from '@/types/admin-product-create'
@@ -127,6 +163,8 @@ defineEmits<{
(event: 'request-create'): void (event: 'request-create'): void
}>() }>()
const openRowId = ref('')
const dataTypeLabelMap = { const dataTypeLabelMap = {
text: 'متنی', text: 'متنی',
number: 'عددی', number: 'عددی',
@@ -136,9 +174,26 @@ const dataTypeLabelMap = {
json: 'JSON', json: 'JSON',
} as const } as const
const syncDisplayOrders = () => {
rows.value.forEach((row, index) => {
row.displayOrder = index
})
}
const getAttribute = (attributeId: string) => {
return props.reusableAttributes.find((attribute) => attribute.id === attributeId)
}
const isRowOpen = (rowId: string) => openRowId.value === rowId
const toggleRow = (rowId: string) => {
openRowId.value = openRowId.value === rowId ? '' : rowId
}
const createEmptyRow = (): ProductAttributeFormRow => ({ const createEmptyRow = (): ProductAttributeFormRow => ({
id: crypto.randomUUID(), id: crypto.randomUUID(),
attributeId: '', attributeId: '',
displayOrder: rows.value.length,
valueText: '', valueText: '',
valueNumber: '', valueNumber: '',
valueBoolean: false, valueBoolean: false,
@@ -147,16 +202,56 @@ const createEmptyRow = (): ProductAttributeFormRow => ({
overrideUnit: '', overrideUnit: '',
}) })
const summaryUnit = (row: ProductAttributeFormRow) => row.overrideUnit || getAttribute(row.attributeId)?.unit || ''
const summaryValue = (row: ProductAttributeFormRow) => {
const attribute = getAttribute(row.attributeId)
if (!attribute) return ''
switch (attribute.dataType) {
case 'number':
return row.valueNumber
case 'boolean':
return row.valueBoolean ? 'بله' : 'خیر'
case 'multiselect':
return row.valueMultiText.join('، ')
case 'json':
return row.valueJson.trim() ? 'JSON' : ''
default:
return row.valueText
}
}
const addRow = () => { const addRow = () => {
rows.value.push(createEmptyRow()) const nextRow = createEmptyRow()
rows.value.push(nextRow)
syncDisplayOrders()
openRowId.value = nextRow.id
} }
const removeRow = (index: number) => { const removeRow = (index: number) => {
const removed = rows.value[index]
rows.value.splice(index, 1) rows.value.splice(index, 1)
syncDisplayOrders()
if (!rows.value.length) {
openRowId.value = ''
return
}
if (removed && openRowId.value === removed.id) {
openRowId.value = rows.value[Math.min(index, rows.value.length - 1)].id
}
} }
const getAttribute = (attributeId: string) => { const moveRow = (index: number, direction: -1 | 1) => {
return props.reusableAttributes.find((attribute) => attribute.id === attributeId) const nextIndex = index + direction
if (nextIndex < 0 || nextIndex >= rows.value.length) return
const [target] = rows.value.splice(index, 1)
rows.value.splice(nextIndex, 0, target)
syncDisplayOrders()
openRowId.value = target.id
} }
const resetRowValues = (row: ProductAttributeFormRow) => { const resetRowValues = (row: ProductAttributeFormRow) => {
@@ -169,6 +264,7 @@ const resetRowValues = (row: ProductAttributeFormRow) => {
const onAttributeChange = (row: ProductAttributeFormRow) => { const onAttributeChange = (row: ProductAttributeFormRow) => {
resetRowValues(row) resetRowValues(row)
openRowId.value = row.id
} }
const toggleMultiValue = (row: ProductAttributeFormRow, value: string) => { const toggleMultiValue = (row: ProductAttributeFormRow, value: string) => {
@@ -179,4 +275,23 @@ const toggleMultiValue = (row: ProductAttributeFormRow, value: string) => {
row.valueMultiText = [...row.valueMultiText, value] row.valueMultiText = [...row.valueMultiText, value]
} }
if (rows.value.length && !openRowId.value) {
openRowId.value = rows.value[0].id
}
watch(
() => rows.value.map((row) => row.id),
(ids) => {
if (!ids.length) {
openRowId.value = ''
return
}
if (!ids.includes(openRowId.value)) {
openRowId.value = ids[0]
}
},
{ immediate: true },
)
</script> </script>

View File

@@ -537,7 +537,7 @@ const validateForm = () => {
if (!form.sku.trim()) return 'SKU الزامی است' if (!form.sku.trim()) return 'SKU الزامی است'
if (!form.slug.trim()) return 'اسلاگ الزامی است' if (!form.slug.trim()) return 'اسلاگ الزامی است'
if (!form.technicalCode.trim()) return 'کد فنی الزامی است' if (!form.technicalCode.trim()) return 'کد فنی الزامی است'
if (!form.brandId && !form.brand.trim()) return 'برند الزامی است' if (!form.brandId && !(form.brand || '').trim()) return 'برند الزامی است'
if (form.basePriceUSD < 0) return 'قیمت پایه نمی تواند منفی باشد' if (form.basePriceUSD < 0) return 'قیمت پایه نمی تواند منفی باشد'
if (form.salePriceUSD !== null && form.salePriceUSD !== undefined && form.salePriceUSD !== '' && Number(form.salePriceUSD) < 0) { if (form.salePriceUSD !== null && form.salePriceUSD !== undefined && form.salePriceUSD !== '' && Number(form.salePriceUSD) < 0) {
return 'قیمت فروش نمی تواند منفی باشد' return 'قیمت فروش نمی تواند منفی باشد'

View File

@@ -249,6 +249,7 @@ const menuSections: MenuSection[] = [
children: [ children: [
{ label: 'تنظیمات عمومی', to: '/admin/settings' }, { label: 'تنظیمات عمومی', to: '/admin/settings' },
{ label: 'درگاه های پرداخت', to: '/admin/settings/payment-gateways' }, { label: 'درگاه های پرداخت', to: '/admin/settings/payment-gateways' },
{ label: 'فیش های بانکی ارسالی', to: '/admin/payments/bank-slips' },
{ label: 'روش های ارسال', to: '/admin/settings/shipping-methods' }, { label: 'روش های ارسال', to: '/admin/settings/shipping-methods' },
{ label: 'تنظیمات سئو', to: '/admin/settings/seo' }, { label: 'تنظیمات سئو', to: '/admin/settings/seo' },
], ],

14
src/config/api.ts Normal file
View File

@@ -0,0 +1,14 @@
const DEFAULT_API_ORIGIN = 'https://parsshop-back.mugit.ir'
const DEFAULT_API_PREFIX = '/api'
const normalizeBaseUrl = (value: string) => {
const trimmed = value.trim()
if (!trimmed) return `${DEFAULT_API_ORIGIN}${DEFAULT_API_PREFIX}`
const withProtocol = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`
return withProtocol.replace(/\/+$/, '')
}
const envBaseUrl = import.meta.env.VITE_API_BASE_URL as string | undefined
export const API_BASE_URL = normalizeBaseUrl(envBaseUrl || `${DEFAULT_API_ORIGIN}${DEFAULT_API_PREFIX}`)

View File

@@ -168,7 +168,24 @@ const router = createRouter({
createAdminPlaceholderRoute('support/product-questions', 'AdminSupportProductQuestions', 'سوالات محصولات', 'مدیریت پرسش و پاسخ های ثبت شده برای کالاها'), createAdminPlaceholderRoute('support/product-questions', 'AdminSupportProductQuestions', 'سوالات محصولات', 'مدیریت پرسش و پاسخ های ثبت شده برای کالاها'),
createAdminPlaceholderRoute('users/roles', 'AdminUsersRoles', 'نقش ها و دسترسی ها', 'مدیریت نقش های سازمانی و سطح دسترسی کاربران پنل'), createAdminPlaceholderRoute('users/roles', 'AdminUsersRoles', 'نقش ها و دسترسی ها', 'مدیریت نقش های سازمانی و سطح دسترسی کاربران پنل'),
createAdminPlaceholderRoute('users/activity-logs', 'AdminUsersActivityLogs', 'لاگ فعالیت ها', 'مشاهده فعالیت های مدیران و اپراتورهای پنل'), createAdminPlaceholderRoute('users/activity-logs', 'AdminUsersActivityLogs', 'لاگ فعالیت ها', 'مشاهده فعالیت های مدیران و اپراتورهای پنل'),
createAdminPlaceholderRoute('settings/payment-gateways', 'AdminSettingsPaymentGateways', 'درگاه های پرداخت', 'تنظیم و مدیریت درگاه های پرداخت فعال'), {
path: 'settings/payment-gateways',
name: 'AdminSettingsPaymentGateways',
component: () => import('@/views/admin/AdminSettingsPaymentGateways.vue'),
meta: {
title: 'درگاه های پرداخت',
description: 'تنظیم و مدیریت درگاه های پرداخت فعال',
},
},
{
path: 'payments/bank-slips',
name: 'AdminPaymentBankSlips',
component: () => import('@/views/admin/AdminPaymentBankSlips.vue'),
meta: {
title: 'فیش های بانکی ارسالی',
description: 'بررسی و تایید یا رد فیش های بانکی ثبت شده',
},
},
createAdminPlaceholderRoute('settings/shipping-methods', 'AdminSettingsShippingMethods', 'روش های ارسال', 'مدیریت روش های ارسال، پیک و پست'), createAdminPlaceholderRoute('settings/shipping-methods', 'AdminSettingsShippingMethods', 'روش های ارسال', 'مدیریت روش های ارسال، پیک و پست'),
createAdminPlaceholderRoute('settings/seo', 'AdminSettingsSeo', 'تنظیمات سئو', 'مدیریت تنظیمات فنی و محتوایی سئو فروشگاه'), createAdminPlaceholderRoute('settings/seo', 'AdminSettingsSeo', 'تنظیمات سئو', 'مدیریت تنظیمات فنی و محتوایی سئو فروشگاه'),
{ {

View File

@@ -82,14 +82,14 @@ export const buildProductFormData = (payload: ProductFormPayload): FormData => {
formData.append('sku', payload.sku) formData.append('sku', payload.sku)
formData.append('title', payload.title) formData.append('title', payload.title)
formData.append('slug', payload.slug) formData.append('slug', payload.slug)
formData.append('summary', payload.summary ?? normalizedMeta.shortDescription)
formData.append('description', payload.description ?? normalizedMeta.description)
formData.append('meta', JSON.stringify(normalizedMeta)) formData.append('meta', JSON.stringify(normalizedMeta))
formData.append('technicalCode', payload.technicalCode) formData.append('technicalCode', payload.technicalCode)
if (payload.brandId !== undefined) { if (payload.brandId !== undefined) {
formData.append('brandId', payload.brandId) formData.append('brandId', payload.brandId)
} }
if (payload.brand) {
formData.append('brand', payload.brand) formData.append('brand', payload.brand)
}
formData.append('basePriceUSD', String(payload.basePriceUSD)) formData.append('basePriceUSD', String(payload.basePriceUSD))
formData.append('stock', String(payload.stock)) formData.append('stock', String(payload.stock))
formData.append('featured', String(payload.featured)) formData.append('featured', String(payload.featured))
@@ -97,14 +97,15 @@ export const buildProductFormData = (payload: ProductFormPayload): FormData => {
formData.append('status', payload.status) formData.append('status', payload.status)
formData.append('attributes', JSON.stringify(payload.attributes || [])) formData.append('attributes', JSON.stringify(payload.attributes || []))
formData.append('tags', JSON.stringify(payload.tags || [])) formData.append('tags', JSON.stringify(payload.tags || []))
formData.append('categoryIds', JSON.stringify(payload.categoryIds || []))
formData.append('existingGalleryUrls', JSON.stringify(payload.existingGalleryUrls || [])) formData.append('existingGalleryUrls', JSON.stringify(payload.existingGalleryUrls || []))
if (payload.salePriceUSD !== undefined && payload.salePriceUSD !== null) { if (payload.salePriceUSD !== undefined && payload.salePriceUSD !== null) {
formData.append('salePriceUSD', String(payload.salePriceUSD)) formData.append('salePriceUSD', String(payload.salePriceUSD))
} }
if (payload.categoryId) { if (payload.primaryCategoryId) {
formData.append('categoryId', payload.categoryId) formData.append('primaryCategoryId', payload.primaryCategoryId)
} }
if (payload.existingMainImageUrl !== undefined) { if (payload.existingMainImageUrl !== undefined) {

View File

@@ -1,4 +1,5 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios' import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import { API_BASE_URL } from '@/config/api'
import type { import type {
ApiResponse, ApiResponse,
AuthResponse, AuthResponse,
@@ -17,7 +18,7 @@ class ApiService {
constructor() { constructor() {
this.api = axios.create({ this.api = axios.create({
baseURL: '/api', // استفاده از proxy baseURL: API_BASE_URL,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json' 'Accept': 'application/json'

View File

@@ -0,0 +1,30 @@
import apiService from '@/services/api'
import type {
BankSlipOrdersResponse,
PaymentMethod,
PaymentMethodCode,
ReviewBankSlipPayload,
ReviewBankSlipResponse,
UpdatePaymentMethodPayload,
} from '@/types/payment'
class PaymentService {
async getPaymentMethods(): Promise<PaymentMethod[]> {
return apiService.get<PaymentMethod[]>('/admin/payments/methods')
}
async updatePaymentMethod(code: PaymentMethodCode, payload: UpdatePaymentMethodPayload): Promise<PaymentMethod> {
return apiService.patch<PaymentMethod, UpdatePaymentMethodPayload>(`/admin/payments/methods/${code}`, payload)
}
async getBankSlipOrders(): Promise<BankSlipOrdersResponse> {
return apiService.get<BankSlipOrdersResponse>('/admin/payments/bank-slip/orders')
}
async reviewBankSlipOrder(orderId: string, payload: ReviewBankSlipPayload): Promise<ReviewBankSlipResponse> {
return apiService.patch<ReviewBankSlipResponse, ReviewBankSlipPayload>(`/admin/payments/bank-slip/orders/${orderId}/review`, payload)
}
}
export const paymentService = new PaymentService()
export default paymentService

View File

@@ -0,0 +1,15 @@
import apiService from '@/services/api'
import type { PricingSettings, UpdatePricingSettingsPayload } from '@/types/settings'
class SettingsService {
async getPricingSettings(): Promise<PricingSettings> {
return apiService.get<PricingSettings>('/admin/settings/pricing')
}
async updatePricingSettings(payload: UpdatePricingSettingsPayload): Promise<PricingSettings> {
return apiService.patch<PricingSettings, UpdatePricingSettingsPayload>('/admin/settings/pricing', payload)
}
}
export const settingsService = new SettingsService()
export default settingsService

View File

@@ -1,6 +1,7 @@
export interface ProductAttributeFormRow { export interface ProductAttributeFormRow {
id: string id: string
attributeId: string attributeId: string
displayOrder: number
valueText: string valueText: string
valueNumber: string valueNumber: string
valueBoolean: boolean valueBoolean: boolean

100
src/types/payment.ts Normal file
View File

@@ -0,0 +1,100 @@
export type PaymentMethodCode =
| 'zarinpal'
| 'saman'
| 'mellat'
| 'pasargad'
| 'bank_slip'
| 'cash_on_delivery'
export type PaymentMethodType = 'online' | 'bank_slip' | 'cash_on_delivery'
export interface PaymentMethod {
id: string
code: PaymentMethodCode
type: PaymentMethodType
title: string
isEnabled: boolean
isSandboxEnabled: boolean
displayOrder: number
description: string | null
instructions: string | null
callbackUrl: string | null
zarinpalMerchantId: string | null
samanTerminalId: string | null
mellatTerminalId: string | null
mellatUsername: string | null
mellatPassword: string | null
pasargadMerchantCode: string | null
pasargadTerminalCode: string | null
pasargadCertificatePem: string | null
bankName: string | null
accountHolderName: string | null
accountNumber: string | null
cardNumber: string | null
shebaNumber: string | null
createdAt: string
updatedAt: string
}
export interface UpdatePaymentMethodPayload {
isEnabled?: boolean
isSandboxEnabled?: boolean
displayOrder?: number
title?: string
description?: string | null
instructions?: string | null
callbackUrl?: string | null
zarinpalMerchantId?: string | null
samanTerminalId?: string | null
mellatTerminalId?: string | null
mellatUsername?: string | null
mellatPassword?: string | null
pasargadMerchantCode?: string | null
pasargadTerminalCode?: string | null
pasargadCertificatePem?: string | null
bankName?: string | null
accountHolderName?: string | null
accountNumber?: string | null
cardNumber?: string | null
shebaNumber?: string | null
}
export interface BankSlipOrderUser {
id: string
fullName: string | null
phone: string | null
username: string | null
}
export interface BankSlipOrder {
id: string
orderNumber: string
status: string
paymentStatus: string
paymentMethod: string
paymentGateway: string | null
totalAmount: number
currency: string
bankSlipTrackingNumber: string | null
bankSlipImageUrl: string | null
bankSlipSubmittedAt: string | null
paymentVerifiedAt: string | null
paymentReviewedAt: string | null
paymentMetadata: Record<string, unknown> | null
updatedAt: string
user: BankSlipOrderUser | null
}
export interface BankSlipOrdersResponse {
items: BankSlipOrder[]
}
export interface ReviewBankSlipPayload {
approved: boolean
adminNote: string
}
export interface ReviewBankSlipResponse {
message: string
order: BankSlipOrder
}

View File

@@ -21,6 +21,7 @@ export interface ProductAttributeOption {
export interface ProductAttributeAssignment { export interface ProductAttributeAssignment {
attributeId?: string attributeId?: string
displayOrder?: number
name?: string name?: string
slug?: string slug?: string
dataType?: ProductAttributeDataType dataType?: ProductAttributeDataType
@@ -202,12 +203,12 @@ export interface ProductFormPayload {
sku: string sku: string
title: string title: string
slug: string slug: string
summary: string summary?: string
description: string description?: string
meta?: ProductMeta meta?: ProductMeta
technicalCode: string technicalCode: string
brandId?: string brandId?: string
brand: string brand?: string
basePriceUSD: number basePriceUSD: number
salePriceUSD?: number | null | '' salePriceUSD?: number | null | ''
stock: number stock: number
@@ -215,6 +216,8 @@ export interface ProductFormPayload {
type: ProductType type: ProductType
status: ProductStatus status: ProductStatus
categoryId?: string categoryId?: string
primaryCategoryId?: string
categoryIds?: string[]
attributes: ProductAttributes | ProductAttributeAssignment[] attributes: ProductAttributes | ProductAttributeAssignment[]
tags: string[] tags: string[]
existingMainImageUrl?: string existingMainImageUrl?: string

14
src/types/settings.ts Normal file
View File

@@ -0,0 +1,14 @@
export type CurrencyDisplay = 'IRR' | 'TOMAN'
export interface PricingSettings {
id: string
usdToIrrRate: number
defaultCurrencyDisplay: CurrencyDisplay
createdAt: string
updatedAt: string
}
export interface UpdatePricingSettingsPayload {
usdToIrrRate: number
defaultCurrencyDisplay: CurrencyDisplay
}

View File

@@ -0,0 +1,160 @@
<template>
<div class="space-y-6">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h1 class="text-3xl font-bold text-black dark:text-white">فیش های بانکی ارسالی</h1>
<p class="mt-1 text-white-dark">فیشهای ثبتشده را بررسی کن، تصویر و شماره پیگیری را ببین و نتیجه را ثبت کن.</p>
</div>
<button type="button" class="btn btn-outline-secondary" :disabled="isLoading" @click="loadOrders">بارگذاری مجدد</button>
</div>
<div v-if="errorMessage" class="rounded-md border border-danger bg-danger/10 px-4 py-3 text-sm text-danger">
{{ errorMessage }}
</div>
<div v-if="isLoading" class="panel grid min-h-[320px] place-content-center">
<span class="inline-flex h-10 w-10 animate-spin rounded-full border-4 border-primary border-l-transparent"></span>
</div>
<div v-else-if="orders.length === 0" class="panel rounded-3xl border border-dashed border-white-light px-4 py-12 text-center text-sm text-white-dark dark:border-[#1b2e4b]">
موردی برای بررسی وجود ندارد.
</div>
<div v-else class="grid grid-cols-1 gap-5 xl:grid-cols-2">
<article
v-for="order in orders"
:key="order.id"
class="overflow-hidden rounded-3xl border border-white-light bg-white shadow-sm dark:border-[#1b2e4b] dark:bg-[#060818]"
>
<div class="border-b border-white-light px-5 py-4 dark:border-[#1b2e4b]">
<div class="flex items-center justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-black dark:text-white">{{ order.orderNumber }}</h2>
<div class="mt-1 text-xs text-white-dark">{{ order.paymentStatus }} / {{ order.status }}</div>
</div>
<span class="badge bg-warning/10 text-warning">منتظر بررسی</span>
</div>
</div>
<div class="grid gap-5 p-5 lg:grid-cols-[220px_minmax(0,1fr)]">
<div class="overflow-hidden rounded-2xl border border-white-light dark:border-[#1b2e4b]">
<img v-if="order.bankSlipImageUrl" :src="order.bankSlipImageUrl" alt="bank slip" class="h-full w-full object-cover" />
<div v-else class="grid h-[220px] place-content-center text-sm text-white-dark">تصویر ندارد</div>
</div>
<div class="space-y-4">
<div class="grid gap-3 sm:grid-cols-2">
<div class="rounded-2xl border border-white-light px-4 py-3 dark:border-[#1b2e4b]">
<div class="text-xs text-white-dark">مبلغ</div>
<div class="mt-1 font-semibold text-black dark:text-white">{{ formatMoney(order.totalAmount, order.currency) }}</div>
</div>
<div class="rounded-2xl border border-white-light px-4 py-3 dark:border-[#1b2e4b]">
<div class="text-xs text-white-dark">شماره پیگیری</div>
<div class="mt-1 font-semibold text-black dark:text-white">{{ order.bankSlipTrackingNumber || '-' }}</div>
</div>
</div>
<div class="rounded-2xl border border-white-light px-4 py-4 dark:border-[#1b2e4b]">
<div class="mb-2 text-sm font-semibold text-black dark:text-white">کاربر</div>
<div class="grid gap-2 text-sm text-white-dark">
<div>نام: {{ order.user?.fullName || '-' }}</div>
<div>موبایل: {{ order.user?.phone || '-' }}</div>
<div>نام کاربری: {{ order.user?.username || '-' }}</div>
</div>
</div>
<div class="rounded-2xl border border-white-light px-4 py-4 dark:border-[#1b2e4b]">
<div class="mb-2 text-sm font-semibold text-black dark:text-white">یادداشت مشتری</div>
<p class="text-sm leading-7 text-white-dark">{{ getCustomerNote(order) || 'یادداشتی ثبت نشده است.' }}</p>
</div>
<div>
<label class="mb-2 block">یادداشت ادمین</label>
<textarea v-model.trim="reviewDrafts[order.id]" rows="4" class="form-textarea" placeholder="تایید شد یا دلیل رد را بنویسید"></textarea>
</div>
<div class="flex flex-wrap gap-2">
<a v-if="order.bankSlipImageUrl" :href="order.bankSlipImageUrl" target="_blank" rel="noreferrer" class="btn btn-outline-secondary btn-sm">نمایش تصویر</a>
<button type="button" class="btn btn-outline-danger btn-sm" :disabled="isSaving(order.id)" @click="reviewOrder(order, false)">رد فیش</button>
<button type="button" class="btn btn-success btn-sm" :disabled="isSaving(order.id)" @click="reviewOrder(order, true)">تایید فیش</button>
</div>
</div>
</div>
</article>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import Swal from 'sweetalert2'
import { extractApiErrorMessage } from '@/services/admin-api'
import paymentService from '@/services/payment-service'
import type { BankSlipOrder } from '@/types/payment'
const orders = ref<BankSlipOrder[]>([])
const isLoading = ref(true)
const errorMessage = ref('')
const savingOrderIds = ref<string[]>([])
const reviewDrafts = reactive<Record<string, string>>({})
const isSaving = (orderId: string) => savingOrderIds.value.includes(orderId)
const loadOrders = async () => {
isLoading.value = true
errorMessage.value = ''
try {
const response = await paymentService.getBankSlipOrders()
orders.value = response.items || []
orders.value.forEach((order) => {
if (reviewDrafts[order.id] === undefined) reviewDrafts[order.id] = ''
})
} catch (error) {
errorMessage.value = extractApiErrorMessage(error, 'بارگذاری فیش‌های بانکی انجام نشد')
} finally {
isLoading.value = false
}
}
const reviewOrder = async (order: BankSlipOrder, approved: boolean) => {
savingOrderIds.value = [...savingOrderIds.value, order.id]
try {
const response = await paymentService.reviewBankSlipOrder(order.id, {
approved,
adminNote: reviewDrafts[order.id]?.trim() || (approved ? 'تایید شد' : 'رد شد'),
})
orders.value = orders.value.filter((item) => item.id !== order.id)
delete reviewDrafts[order.id]
await Swal.fire({
icon: 'success',
title: approved ? 'فیش تایید شد' : 'فیش رد شد',
text: response.message,
timer: 1500,
showConfirmButton: false,
})
} catch (error) {
await Swal.fire({
icon: 'error',
title: 'خطا',
text: extractApiErrorMessage(error, 'ثبت نتیجه بررسی فیش بانکی انجام نشد'),
})
} finally {
savingOrderIds.value = savingOrderIds.value.filter((item) => item !== order.id)
}
}
const getCustomerNote = (order: BankSlipOrder) => (typeof order.paymentMetadata?.bankSlipNotes === 'string' ? order.paymentMetadata.bankSlipNotes : '')
const formatMoney = (amount: number, currency: string) => {
const formatted = new Intl.NumberFormat('fa-IR').format(Number(amount || 0))
return `${formatted} ${currency === 'TOMAN' ? 'تومان' : currency === 'IRR' ? 'ریال' : currency}`
}
onMounted(async () => {
await loadOrders()
})
</script>

View File

@@ -1,10 +1,10 @@
<template> <template>
<ProductForm mode="edit" :product-id="String(route.params.id)" /> <AdminProductCreateForm mode="edit" :product-id="String(route.params.id || '')" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import ProductForm from '@/components/admin/products/ProductForm.vue' import AdminProductCreateForm from '@/components/admin/products/AdminProductCreateForm.vue'
import { useMeta } from '@/composables/use-meta' import { useMeta } from '@/composables/use-meta'
const route = useRoute() const route = useRoute()

View File

@@ -1,8 +1,227 @@
<template> <template>
<div class="space-y-6">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div> <div>
<h1 class="text-3xl font-bold text-black dark:text-white mb-8">تنظیمات سیستم</h1> <h1 class="text-3xl font-bold text-black dark:text-white">تنظیمات سیستم</h1>
<p class="mt-1 text-white-dark">تنظیمات مالی فروشگاه را از این بخش مدیریت کنید تا قیمتگذاری محصولات بر اساس نرخ واحد انجام شود.</p>
</div>
<div class="flex flex-wrap gap-3">
<button type="button" class="btn btn-outline-secondary" :disabled="isLoading || isSubmitting" @click="loadPricingSettings">
بارگذاری مجدد
</button>
</div>
</div>
<div class="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1.55fr)_minmax(320px,0.85fr)]">
<div class="panel"> <div class="panel">
<p class="text-white-dark">تنظیمات پنل مدیریت</p> <div class="mb-5 flex items-start justify-between gap-4">
<div>
<div class="inline-flex rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold text-primary">مالی</div>
<h2 class="mt-3 text-xl font-semibold text-black dark:text-white">تنظیمات قیمت</h2>
<p class="mt-1 text-sm text-white-dark">نرخ تبدیل دلار به ریال و واحد نمایش پیشفرض قیمت محصولات را مشخص کنید.</p>
</div>
</div>
<div v-if="errorMessage" class="mb-5 rounded-md border border-danger bg-danger/10 px-4 py-3 text-sm text-danger">
{{ errorMessage }}
</div>
<div v-if="isLoading" class="grid min-h-[320px] place-content-center">
<span class="inline-flex h-10 w-10 animate-spin rounded-full border-4 border-primary border-l-transparent"></span>
</div>
<form v-else class="space-y-5" @submit.prevent="submitPricingSettings">
<div class="grid gap-5 lg:grid-cols-2">
<div>
<label for="pricing-usd-rate">نرخ دلار به ریال</label>
<input
id="pricing-usd-rate"
v-model.number="form.usdToIrrRate"
type="number"
min="0"
step="1"
class="form-input"
placeholder="مثلاً 925000"
/>
<p class="mt-2 text-xs text-white-dark">این عدد مبنای تبدیل قیمت دلاری محصولات در کل فروشگاه است.</p>
</div>
<div>
<label for="pricing-currency-display">واحد نمایش پیشفرض</label>
<select id="pricing-currency-display" v-model="form.defaultCurrencyDisplay" class="form-select">
<option v-for="option in currencyDisplayOptions" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>
<p class="mt-2 text-xs text-white-dark">کاربران قیمتها را بهصورت پیشفرض با این واحد میبینند.</p>
</div>
</div>
<div class="rounded-2xl border border-white-light bg-white-light/30 px-4 py-4 dark:border-[#1b2e4b] dark:bg-[#060818]">
<div class="mb-3 text-sm font-semibold text-black dark:text-white">پیشنمایش سریع</div>
<div class="grid gap-3 md:grid-cols-2">
<div class="rounded-xl bg-white px-4 py-4 shadow-sm dark:bg-[#0e1726]">
<div class="text-xs text-white-dark">نرخ فعلی</div>
<div class="mt-2 text-lg font-bold text-black dark:text-white">{{ formatNumber(form.usdToIrrRate) }} ریال</div>
</div>
<div class="rounded-xl bg-white px-4 py-4 shadow-sm dark:bg-[#0e1726]">
<div class="text-xs text-white-dark">نمایش پیشفرض</div>
<div class="mt-2 text-lg font-bold text-black dark:text-white">{{ selectedCurrencyLabel }}</div>
</div>
</div>
</div>
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-white-light pt-5 dark:border-[#1b2e4b]">
<div class="text-xs leading-6 text-white-dark">
<div>ایجاد: {{ pricingSettings ? formatDateTime(pricingSettings.createdAt) : '-' }}</div>
<div>آخرین بروزرسانی: {{ pricingSettings ? formatDateTime(pricingSettings.updatedAt) : '-' }}</div>
</div>
<div class="flex flex-wrap gap-2">
<button type="button" class="btn btn-outline-secondary" :disabled="isSubmitting" @click="resetForm">بازنشانی</button>
<button type="submit" class="btn btn-primary min-w-[140px]" :disabled="isSubmitting">
<span v-if="isSubmitting">در حال ذخیره...</span>
<span v-else>ذخیره تنظیمات</span>
</button>
</div>
</div>
</form>
</div>
<div class="space-y-6">
<div class="panel">
<h3 class="text-lg font-semibold text-black dark:text-white">نکات این بخش</h3>
<div class="mt-4 space-y-3 text-sm leading-7 text-white-dark">
<p>بعد از ذخیره موفق، بکاند cache قیمت محصولات را invalidate میکند و نیازی به refresh دستی از سمت فرانت نیست.</p>
<p>اگر هنوز تنظیمی در دیتابیس نباشد، بکاند یک رکورد پیشفرض میسازد و این صفحه همان را لود میکند.</p>
<p>برای جلوگیری از خطای اعتبارسنجی، نرخ دلار فقط باید عددی بزرگتر یا مساوی صفر باشد.</p>
</div>
</div>
<div class="panel">
<h3 class="text-lg font-semibold text-black dark:text-white">واحدهای مجاز</h3>
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="rounded-xl border border-white-light px-4 py-4 dark:border-[#1b2e4b]">
<div class="text-sm font-semibold text-black dark:text-white">IRR</div>
<div class="mt-1 text-xs text-white-dark">نمایش قیمت بر اساس ریال</div>
</div>
<div class="rounded-xl border border-white-light px-4 py-4 dark:border-[#1b2e4b]">
<div class="text-sm font-semibold text-black dark:text-white">TOMAN</div>
<div class="mt-1 text-xs text-white-dark">نمایش قیمت بر اساس تومان</div>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import Swal from 'sweetalert2'
import { extractApiErrorMessage } from '@/services/admin-api'
import settingsService from '@/services/settings-service'
import type { CurrencyDisplay, PricingSettings } from '@/types/settings'
const currencyDisplayOptions: Array<{ value: CurrencyDisplay; label: string }> = [
{ value: 'IRR', label: 'IRR' },
{ value: 'TOMAN', label: 'TOMAN' },
]
const pricingSettings = ref<PricingSettings | null>(null)
const isLoading = ref(true)
const isSubmitting = ref(false)
const errorMessage = ref('')
const form = reactive<{
usdToIrrRate: number
defaultCurrencyDisplay: CurrencyDisplay
}>({
usdToIrrRate: 0,
defaultCurrencyDisplay: 'IRR',
})
const selectedCurrencyLabel = computed(() => {
return currencyDisplayOptions.find((option) => option.value === form.defaultCurrencyDisplay)?.label || form.defaultCurrencyDisplay
})
const syncForm = (settings: PricingSettings) => {
pricingSettings.value = settings
form.usdToIrrRate = Number(settings.usdToIrrRate ?? 0)
form.defaultCurrencyDisplay = settings.defaultCurrencyDisplay
}
const loadPricingSettings = async () => {
isLoading.value = true
errorMessage.value = ''
try {
const response = await settingsService.getPricingSettings()
syncForm(response)
} catch (error) {
errorMessage.value = extractApiErrorMessage(error, 'بارگذاری تنظیمات قیمت انجام نشد')
} finally {
isLoading.value = false
}
}
const resetForm = () => {
if (!pricingSettings.value) return
syncForm(pricingSettings.value)
errorMessage.value = ''
}
const formatNumber = (value: number) => new Intl.NumberFormat('fa-IR').format(Number(value || 0))
const formatDateTime = (value: string) => {
if (!value) return '-'
try {
return new Intl.DateTimeFormat('fa-IR', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value))
} catch {
return value
}
}
const submitPricingSettings = async () => {
errorMessage.value = ''
if (!Number.isFinite(Number(form.usdToIrrRate)) || Number(form.usdToIrrRate) < 0) {
errorMessage.value = 'نرخ دلار به ریال باید عددی معتبر و بزرگ‌تر یا مساوی صفر باشد'
return
}
isSubmitting.value = true
try {
const response = await settingsService.updatePricingSettings({
usdToIrrRate: Number(form.usdToIrrRate),
defaultCurrencyDisplay: form.defaultCurrencyDisplay,
})
syncForm(response)
await Swal.fire({
icon: 'success',
title: 'تنظیمات ذخیره شد',
text: 'تنظیمات قیمت با موفقیت بروزرسانی شد',
timer: 1500,
showConfirmButton: false,
})
} catch (error) {
errorMessage.value = extractApiErrorMessage(error, 'ذخیره تنظیمات قیمت انجام نشد')
await Swal.fire({
icon: 'error',
title: 'خطا',
text: errorMessage.value,
})
} finally {
isSubmitting.value = false
}
}
onMounted(async () => {
await loadPricingSettings()
})
</script>

View File

@@ -0,0 +1,378 @@
<template>
<div class="space-y-6">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h1 class="text-3xl font-bold text-black dark:text-white">درگاه های پرداخت</h1>
<p class="mt-1 text-white-dark">درگاههای بانکی را بهصورت کارتهای مستقل مدیریت کنید. هر کارت با کلیک باز میشود و فقط فیلدهای خودش را نشان میدهد.</p>
</div>
<button type="button" class="btn btn-outline-secondary" :disabled="isLoading" @click="loadPaymentMethods">بارگذاری مجدد</button>
</div>
<div class="grid grid-cols-1 gap-4 xl:grid-cols-4">
<div class="panel">
<div class="text-xs text-white-dark">کل درگاهها</div>
<div class="mt-2 text-2xl font-bold text-black dark:text-white">{{ paymentMethods.length }}</div>
</div>
<div class="panel">
<div class="text-xs text-white-dark">فعال</div>
<div class="mt-2 text-2xl font-bold text-success">{{ enabledCount }}</div>
</div>
<div class="panel">
<div class="text-xs text-white-dark">حالت تست</div>
<div class="mt-2 text-2xl font-bold text-primary">{{ sandboxEnabledCount }}</div>
</div>
<div class="panel">
<div class="text-xs text-white-dark">مرتبسازی</div>
<div class="mt-2 text-2xl font-bold text-warning">{{ sortedMethods.length ? sortedMethods[0].displayOrder : 0 }}+</div>
</div>
</div>
<div v-if="pageErrorMessage" class="rounded-md border border-danger bg-danger/10 px-4 py-3 text-sm text-danger">
{{ pageErrorMessage }}
</div>
<div v-if="isLoading" class="panel grid min-h-[320px] place-content-center">
<span class="inline-flex h-10 w-10 animate-spin rounded-full border-4 border-primary border-l-transparent"></span>
</div>
<div v-else class="grid grid-cols-1 gap-5 xl:grid-cols-[minmax(0,1.5fr)_340px]">
<div class="space-y-4">
<article
v-for="method in sortedMethods"
:key="method.code"
class="overflow-hidden rounded-3xl border border-white-light bg-white shadow-sm transition dark:border-[#1b2e4b] dark:bg-[#060818]"
>
<button
type="button"
class="flex w-full items-start justify-between gap-4 px-5 py-5 text-right transition hover:bg-white-light/30 dark:hover:bg-white/5"
@click="toggleMethod(method.code)"
>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h2 class="text-lg font-semibold text-black dark:text-white">{{ methodDrafts[method.code].title || method.title }}</h2>
<span class="badge bg-primary/10 text-primary">{{ method.code }}</span>
<span class="badge" :class="methodDrafts[method.code].isEnabled ? 'bg-success/15 text-success' : 'bg-danger/15 text-danger'">
{{ methodDrafts[method.code].isEnabled ? 'فعال' : 'غیرفعال' }}
</span>
<span v-if="method.type === 'online' && methodDrafts[method.code].isSandboxEnabled" class="badge bg-warning/15 text-warning">Sandbox</span>
</div>
<div class="mt-2 flex flex-wrap items-center gap-3 text-xs text-white-dark">
<span>ترتیب {{ methodDrafts[method.code].displayOrder }}</span>
<span>{{ methodDrafts[method.code].description || 'توضیحی ثبت نشده است.' }}</span>
</div>
</div>
<span class="text-xl leading-none text-white-dark transition" :class="openMethodCode === method.code ? 'rotate-45 text-primary' : ''">+</span>
</button>
<div v-if="openMethodCode === method.code" class="border-t border-white-light px-5 py-5 dark:border-[#1b2e4b]">
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="mb-2 block">عنوان</label>
<input v-model.trim="methodDrafts[method.code].title" type="text" class="form-input" />
</div>
<div>
<label class="mb-2 block">ترتیب نمایش</label>
<input v-model.number="methodDrafts[method.code].displayOrder" type="number" min="0" class="form-input" />
</div>
</div>
<div class="mt-4 grid gap-4 md:grid-cols-2">
<label class="flex items-center gap-3 rounded-2xl border border-white-light px-4 py-3 dark:border-[#1b2e4b]">
<input v-model="methodDrafts[method.code].isEnabled" type="checkbox" class="form-checkbox outline-primary" />
<span class="text-sm text-black dark:text-white">فعال باشد</span>
</label>
<label
v-if="method.type === 'online'"
class="flex items-center gap-3 rounded-2xl border border-white-light px-4 py-3 dark:border-[#1b2e4b]"
>
<input v-model="methodDrafts[method.code].isSandboxEnabled" type="checkbox" class="form-checkbox outline-primary" />
<span class="text-sm text-black dark:text-white">حالت آزمایشی</span>
</label>
</div>
<div class="mt-4">
<label class="mb-2 block">توضیحات</label>
<textarea v-model.trim="methodDrafts[method.code].description" rows="3" class="form-textarea"></textarea>
</div>
<div class="mt-4">
<label class="mb-2 block">راهنما</label>
<textarea v-model.trim="methodDrafts[method.code].instructions" rows="3" class="form-textarea"></textarea>
</div>
<div class="mt-5 grid gap-4 md:grid-cols-2">
<div v-if="showsField(method.code, 'callbackUrl')">
<label class="mb-2 block">Callback URL</label>
<input v-model.trim="methodDrafts[method.code].callbackUrl" type="text" dir="ltr" class="form-input" />
</div>
<div v-if="showsField(method.code, 'zarinpalMerchantId')">
<label class="mb-2 block">Merchant ID</label>
<input v-model.trim="methodDrafts[method.code].zarinpalMerchantId" type="text" dir="ltr" class="form-input" />
</div>
<div v-if="showsField(method.code, 'samanTerminalId')">
<label class="mb-2 block">Terminal ID</label>
<input v-model.trim="methodDrafts[method.code].samanTerminalId" type="text" dir="ltr" class="form-input" />
</div>
<div v-if="showsField(method.code, 'mellatTerminalId')">
<label class="mb-2 block">Terminal ID</label>
<input v-model.trim="methodDrafts[method.code].mellatTerminalId" type="text" dir="ltr" class="form-input" />
</div>
<div v-if="showsField(method.code, 'mellatUsername')">
<label class="mb-2 block">Username</label>
<input v-model.trim="methodDrafts[method.code].mellatUsername" type="text" dir="ltr" class="form-input" />
</div>
<div v-if="showsField(method.code, 'mellatPassword')">
<label class="mb-2 block">Password</label>
<input v-model.trim="methodDrafts[method.code].mellatPassword" type="password" dir="ltr" class="form-input" />
</div>
<div v-if="showsField(method.code, 'pasargadMerchantCode')">
<label class="mb-2 block">Merchant Code</label>
<input v-model.trim="methodDrafts[method.code].pasargadMerchantCode" type="text" dir="ltr" class="form-input" />
</div>
<div v-if="showsField(method.code, 'pasargadTerminalCode')">
<label class="mb-2 block">Terminal Code</label>
<input v-model.trim="methodDrafts[method.code].pasargadTerminalCode" type="text" dir="ltr" class="form-input" />
</div>
<div v-if="showsField(method.code, 'bankName')">
<label class="mb-2 block">نام بانک</label>
<input v-model.trim="methodDrafts[method.code].bankName" type="text" class="form-input" />
</div>
<div v-if="showsField(method.code, 'accountHolderName')">
<label class="mb-2 block">نام صاحب حساب</label>
<input v-model.trim="methodDrafts[method.code].accountHolderName" type="text" class="form-input" />
</div>
<div v-if="showsField(method.code, 'accountNumber')">
<label class="mb-2 block">شماره حساب</label>
<input v-model.trim="methodDrafts[method.code].accountNumber" type="text" dir="ltr" class="form-input" />
</div>
<div v-if="showsField(method.code, 'cardNumber')">
<label class="mb-2 block">شماره کارت</label>
<input v-model.trim="methodDrafts[method.code].cardNumber" type="text" dir="ltr" class="form-input" />
</div>
<div v-if="showsField(method.code, 'shebaNumber')" class="md:col-span-2">
<label class="mb-2 block">شماره شبا</label>
<input v-model.trim="methodDrafts[method.code].shebaNumber" type="text" dir="ltr" class="form-input" />
</div>
</div>
<div v-if="showsField(method.code, 'pasargadCertificatePem')" class="mt-4">
<label class="mb-2 block">Certificate PEM</label>
<textarea v-model.trim="methodDrafts[method.code].pasargadCertificatePem" rows="6" class="form-textarea font-mono text-sm" dir="ltr"></textarea>
</div>
<div class="mt-5 flex flex-wrap justify-end gap-2 border-t border-white-light pt-4 dark:border-[#1b2e4b]">
<button type="button" class="btn btn-outline-secondary btn-sm" :disabled="isMethodSaving(method.code)" @click="resetMethodDraft(method.code)">بازنشانی</button>
<button type="button" class="btn btn-primary btn-sm" :disabled="isMethodSaving(method.code)" @click="saveMethod(method.code)">
<span v-if="isMethodSaving(method.code)">در حال ذخیره...</span>
<span v-else>ذخیره تنظیمات</span>
</button>
</div>
</div>
</article>
</div>
<aside class="space-y-6">
<div class="panel">
<h3 class="text-lg font-semibold text-black dark:text-white">راهنمای سریع</h3>
<div class="mt-4 space-y-3 text-sm leading-7 text-white-dark">
<p>هر درگاه یک کارت دارد و با کلیک باز میشود.</p>
<p>فقط فیلدهای مرتبط با همان `code` نمایش داده میشوند.</p>
<p>در صورت فعالسازی با داده ناقص، بکاند خطای `400` برمیگرداند.</p>
</div>
</div>
<div class="panel">
<h3 class="text-lg font-semibold text-black dark:text-white">حداقل داده لازم</h3>
<div class="mt-4 space-y-3 text-sm leading-7 text-white-dark">
<p>زرینپال: `Merchant ID` و `Callback URL`</p>
<p>سامان: `Terminal ID` و `Callback URL`</p>
<p>ملت: `Terminal ID`، `Username`، `Password` و `Callback URL`</p>
<p>پاسارگاد: `Merchant Code`، `Terminal Code`، `Certificate PEM` و `Callback URL`</p>
<p>فیش بانکی: `نام بانک`، `نام صاحب حساب` و `شماره حساب`</p>
</div>
</div>
</aside>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import Swal from 'sweetalert2'
import { extractApiErrorMessage } from '@/services/admin-api'
import paymentService from '@/services/payment-service'
import type { PaymentMethod, PaymentMethodCode } from '@/types/payment'
type PaymentMethodDraft = {
title: string
isEnabled: boolean
isSandboxEnabled: boolean
displayOrder: number
description: string
instructions: string
callbackUrl: string
zarinpalMerchantId: string
samanTerminalId: string
mellatTerminalId: string
mellatUsername: string
mellatPassword: string
pasargadMerchantCode: string
pasargadTerminalCode: string
pasargadCertificatePem: string
bankName: string
accountHolderName: string
accountNumber: string
cardNumber: string
shebaNumber: string
}
const paymentMethods = ref<PaymentMethod[]>([])
const isLoading = ref(true)
const pageErrorMessage = ref('')
const savingMethodCodes = ref<string[]>([])
const openMethodCode = ref<PaymentMethodCode | ''>('')
const methodDrafts = reactive<Record<string, PaymentMethodDraft>>({})
const toDraft = (method: PaymentMethod): PaymentMethodDraft => ({
title: method.title || '',
isEnabled: Boolean(method.isEnabled),
isSandboxEnabled: Boolean(method.isSandboxEnabled),
displayOrder: Number(method.displayOrder ?? 0),
description: method.description || '',
instructions: method.instructions || '',
callbackUrl: method.callbackUrl || '',
zarinpalMerchantId: method.zarinpalMerchantId || '',
samanTerminalId: method.samanTerminalId || '',
mellatTerminalId: method.mellatTerminalId || '',
mellatUsername: method.mellatUsername || '',
mellatPassword: method.mellatPassword || '',
pasargadMerchantCode: method.pasargadMerchantCode || '',
pasargadTerminalCode: method.pasargadTerminalCode || '',
pasargadCertificatePem: method.pasargadCertificatePem || '',
bankName: method.bankName || '',
accountHolderName: method.accountHolderName || '',
accountNumber: method.accountNumber || '',
cardNumber: method.cardNumber || '',
shebaNumber: method.shebaNumber || '',
})
const syncMethodDraft = (method: PaymentMethod) => {
methodDrafts[method.code] = toDraft(method)
}
const sortedMethods = computed(() => [...paymentMethods.value].sort((a, b) => a.displayOrder - b.displayOrder))
const enabledCount = computed(() => paymentMethods.value.filter((method) => method.isEnabled).length)
const sandboxEnabledCount = computed(() => paymentMethods.value.filter((method) => method.isSandboxEnabled).length)
const isMethodSaving = (code: string) => savingMethodCodes.value.includes(code)
const showsField = (code: PaymentMethodCode, field: string) => {
const fieldMap: Record<PaymentMethodCode, string[]> = {
zarinpal: ['callbackUrl', 'zarinpalMerchantId'],
saman: ['callbackUrl', 'samanTerminalId'],
mellat: ['callbackUrl', 'mellatTerminalId', 'mellatUsername', 'mellatPassword'],
pasargad: ['callbackUrl', 'pasargadMerchantCode', 'pasargadTerminalCode', 'pasargadCertificatePem'],
bank_slip: ['bankName', 'accountHolderName', 'accountNumber', 'cardNumber', 'shebaNumber'],
cash_on_delivery: [],
}
return fieldMap[code].includes(field)
}
const loadPaymentMethods = async () => {
isLoading.value = true
pageErrorMessage.value = ''
try {
const response = await paymentService.getPaymentMethods()
paymentMethods.value = response
response.forEach(syncMethodDraft)
if (!openMethodCode.value && response.length) {
openMethodCode.value = response[0].code
}
} catch (error) {
pageErrorMessage.value = extractApiErrorMessage(error, 'بارگذاری روش‌های پرداخت انجام نشد')
} finally {
isLoading.value = false
}
}
const toggleMethod = (code: PaymentMethodCode) => {
openMethodCode.value = openMethodCode.value === code ? '' : code
}
const resetMethodDraft = (code: PaymentMethodCode) => {
const method = paymentMethods.value.find((item) => item.code === code)
if (!method) return
syncMethodDraft(method)
}
const buildPayload = (code: PaymentMethodCode) => {
const draft = methodDrafts[code]
return {
title: draft.title.trim(),
isEnabled: draft.isEnabled,
isSandboxEnabled: draft.isSandboxEnabled,
displayOrder: Math.max(0, Number(draft.displayOrder || 0)),
description: draft.description.trim() || null,
instructions: draft.instructions.trim() || null,
callbackUrl: draft.callbackUrl.trim() || null,
zarinpalMerchantId: draft.zarinpalMerchantId.trim() || null,
samanTerminalId: draft.samanTerminalId.trim() || null,
mellatTerminalId: draft.mellatTerminalId.trim() || null,
mellatUsername: draft.mellatUsername.trim() || null,
mellatPassword: draft.mellatPassword.trim() || null,
pasargadMerchantCode: draft.pasargadMerchantCode.trim() || null,
pasargadTerminalCode: draft.pasargadTerminalCode.trim() || null,
pasargadCertificatePem: draft.pasargadCertificatePem.trim() || null,
bankName: draft.bankName.trim() || null,
accountHolderName: draft.accountHolderName.trim() || null,
accountNumber: draft.accountNumber.trim() || null,
cardNumber: draft.cardNumber.trim() || null,
shebaNumber: draft.shebaNumber.trim() || null,
}
}
const saveMethod = async (code: PaymentMethodCode) => {
savingMethodCodes.value = [...savingMethodCodes.value, code]
try {
const updatedMethod = await paymentService.updatePaymentMethod(code, buildPayload(code))
paymentMethods.value = paymentMethods.value.map((item) => (item.code === code ? updatedMethod : item))
syncMethodDraft(updatedMethod)
await Swal.fire({
icon: 'success',
title: 'ذخیره شد',
text: `تنظیمات ${updatedMethod.title} بروزرسانی شد`,
timer: 1400,
showConfirmButton: false,
})
} catch (error) {
await Swal.fire({
icon: 'error',
title: 'خطا',
text: extractApiErrorMessage(error, 'ذخیره روش پرداخت انجام نشد'),
})
} finally {
savingMethodCodes.value = savingMethodCodes.value.filter((item) => item !== code)
}
}
onMounted(async () => {
await loadPaymentMethods()
})
</script>

View File

@@ -11,13 +11,7 @@ export default defineConfig({
} }
}, },
server: { server: {
port: 5173, host: '0.0.0.0',
proxy: { port: 5173
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
secure: false
}
}
} }
}) })