for test
This commit is contained in:
1
.env.example
Normal file
1
.env.example
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=https://parsshop-back.mugit.ir/api
|
||||
@@ -5,11 +5,11 @@
|
||||
<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]">
|
||||
<span class="h-2 w-2 rounded-full bg-primary"></span>
|
||||
ایجاد محصول
|
||||
{{ pageBadgeLabel }}
|
||||
</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">
|
||||
فرم به چند باکس مشخص تقسیم شده تا اطلاعات پایه، محتوا، سئو، رسانه و ویژگیها از هم جدا باشند و صفحه شلوغ نشود.
|
||||
{{ pageDescription }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<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">
|
||||
<span v-if="isSubmitting">در حال ذخیره...</span>
|
||||
<span v-else>ذخیره محصول</span>
|
||||
<span v-else>{{ props.mode === 'edit' ? 'ذخیره تغییرات' : 'ذخیره محصول' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -121,52 +121,90 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="baseInfoTab === 'categories'" class="grid grid-cols-1 gap-5 xl:grid-cols-[220px_minmax(0,1fr)_260px]">
|
||||
<div>
|
||||
<label for="product-type">نوع محصول</label>
|
||||
<select id="product-type" v-model="form.type" class="form-select">
|
||||
<div v-else-if="baseInfoTab === 'categories'" class="grid grid-cols-1 gap-5 xl:grid-cols-[240px_minmax(0,1fr)_280px]">
|
||||
<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" 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-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>
|
||||
</select>
|
||||
<div class="mb-2 flex items-center justify-between gap-3">
|
||||
<span class="text-xs text-white-dark">نمای درختی</span>
|
||||
<button v-if="form.categoryId" type="button" class="text-xs text-danger hover:underline" @click="form.categoryId = ''">پاک کردن انتخاب</button>
|
||||
<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">
|
||||
<div class="text-xs text-white-dark">نوع انتخابشده</div>
|
||||
<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>
|
||||
<input id="product-category-search" v-model.trim="categorySearch" type="text" class="form-input" placeholder="جستجو در دسته‌بندی‌ها..." />
|
||||
<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">
|
||||
<label
|
||||
v-for="row in visibleCategoryRows"
|
||||
: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]"
|
||||
: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)" />
|
||||
<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="text-[11px] text-white-dark">{{ row.slug }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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]">
|
||||
دسته‌بندی مطابق جستجو پیدا نشد.
|
||||
<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
|
||||
v-for="row in visibleCategoryRows"
|
||||
:key="row.id"
|
||||
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` }"
|
||||
>
|
||||
<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="block truncate text-sm font-semibold text-black dark:text-white">{{ row.name }}</span>
|
||||
<span class="mt-1 block text-[11px] text-white-dark">{{ row.slug }}</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>
|
||||
</div>
|
||||
<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 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>
|
||||
<p v-if="selectedCategoryLabel" class="mt-2 text-xs text-success">انتخاب ÙØ¹Ù„ÛŒ: {{ selectedCategoryLabel }}</p>
|
||||
</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>
|
||||
<label for="product-brand">برند</label>
|
||||
<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 v-for="brand in filteredBrands" :key="brand.id" :value="brand.id">{{ brand.name }}</option>
|
||||
</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>
|
||||
|
||||
@@ -260,7 +298,7 @@
|
||||
</div>
|
||||
</AdminFormSection>
|
||||
|
||||
<AdminFormSection title="رسانهها" description="برای هر رسانه میتوانید فایل جدید آپلود کنید یا از Media Library انتخاب کنید.">
|
||||
<AdminFormSection title="رسانهها" description="برای هر رسانه، فایل را فقط از طریق Media Library انتخاب کنید.">
|
||||
<template #icon>
|
||||
<IconGallery class="h-5 w-5" />
|
||||
</template>
|
||||
@@ -281,17 +319,15 @@
|
||||
</div>
|
||||
<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-secondary btn-sm" @click="modelInput?.click()">آپلود فایل جدید</button>
|
||||
<button v-if="modelDisplayName" type="button" class="btn btn-outline-danger btn-sm" @click="clearModelFile">حذف</button>
|
||||
</div>
|
||||
<input ref="modelInput" type="file" accept=".glb,.gltf,.obj,.usdz,.zip,model/*" class="hidden" @change="onModelSelected" />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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 class="rounded-full border border-white-light px-3 py-1 text-xs text-white-dark dark:border-[#1b2e4b]">
|
||||
{{ galleryAssets.length }} آیتم
|
||||
@@ -314,10 +350,8 @@
|
||||
|
||||
<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-secondary btn-sm" @click="galleryInput?.click()">آپلود چند فایل</button>
|
||||
<button v-if="galleryAssets.length" type="button" class="btn btn-outline-danger btn-sm" @click="clearGalleryAssets">پاک کردن همه</button>
|
||||
</div>
|
||||
<input ref="galleryInput" type="file" accept="image/*" multiple class="hidden" @change="onGalleryFilesSelected" />
|
||||
</div>
|
||||
</div>
|
||||
</AdminFormSection>
|
||||
@@ -383,10 +417,8 @@
|
||||
</div>
|
||||
<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-secondary btn-sm" @click="mainImageInput?.click()">آپلود فایل جدید</button>
|
||||
<button v-if="mainImagePreview" type="button" class="btn btn-outline-danger btn-sm" @click="clearMainImage">حذف</button>
|
||||
</div>
|
||||
<input ref="mainImageInput" type="file" accept="image/*" class="hidden" @change="onMainImageSelected" />
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -536,6 +568,7 @@ import type {
|
||||
ProductAttributeDataType,
|
||||
ProductAttributeOption,
|
||||
ProductFormPayload,
|
||||
ProductListItem,
|
||||
ProductMeta,
|
||||
ProductStatus,
|
||||
ProductType,
|
||||
@@ -546,6 +579,7 @@ import {
|
||||
filterCategoriesByType,
|
||||
formatCurrency,
|
||||
generateEnglishSlug,
|
||||
mapProductToForm,
|
||||
productStatusOptions,
|
||||
productTypeOptions,
|
||||
slugify,
|
||||
@@ -570,6 +604,17 @@ type CategoryTreeRow = {
|
||||
children: CategoryTreeRow[]
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
mode?: 'create' | 'edit'
|
||||
productId?: string
|
||||
}>(),
|
||||
{
|
||||
mode: 'create',
|
||||
productId: '',
|
||||
},
|
||||
)
|
||||
|
||||
const MAX_SLUG_LENGTH = 16
|
||||
const SLUG_CHECK_DELAY = 600
|
||||
|
||||
@@ -594,9 +639,10 @@ const createEmptyMeta = (): ProductMeta => ({
|
||||
shareImageUrl: '',
|
||||
})
|
||||
|
||||
const createEmptyAttributeRow = (): ProductAttributeFormRow => ({
|
||||
const createEmptyAttributeRow = (displayOrder = 0): ProductAttributeFormRow => ({
|
||||
id: crypto.randomUUID(),
|
||||
attributeId: '',
|
||||
displayOrder,
|
||||
valueText: '',
|
||||
valueNumber: '',
|
||||
valueBoolean: false,
|
||||
@@ -605,6 +651,12 @@ const createEmptyAttributeRow = (): ProductAttributeFormRow => ({
|
||||
overrideUnit: '',
|
||||
})
|
||||
|
||||
const normalizeAttributeDisplayOrders = (rows: ProductAttributeFormRow[]) =>
|
||||
rows.map((row, index) => ({
|
||||
...row,
|
||||
displayOrder: index,
|
||||
}))
|
||||
|
||||
const createEmptyReusableAttributeForm = () => ({
|
||||
name: '',
|
||||
slug: '',
|
||||
@@ -642,7 +694,7 @@ const form = reactive({
|
||||
const categories = ref<Category[]>([])
|
||||
const brands = ref<Brand[]>([])
|
||||
const reusableAttributes = ref<ReusableProductAttribute[]>([])
|
||||
const attributeRows = ref<ProductAttributeFormRow[]>([createEmptyAttributeRow()])
|
||||
const attributeRows = ref<ProductAttributeFormRow[]>([createEmptyAttributeRow(0)])
|
||||
const tagInput = ref('')
|
||||
const validationErrors = ref<string[]>([])
|
||||
const errorMessage = ref('')
|
||||
@@ -661,9 +713,6 @@ const isCreateAttributeModalOpen = ref(false)
|
||||
const isCreatingAttribute = ref(false)
|
||||
const createAttributeForm = reactive(createEmptyReusableAttributeForm())
|
||||
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 mainImagePreview = ref('')
|
||||
const mainImageObjectUrl = ref('')
|
||||
@@ -739,6 +788,22 @@ const checkedCategoryIds = computed(() => {
|
||||
return ids
|
||||
})
|
||||
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 query = categorySearch.value.trim().toLowerCase()
|
||||
|
||||
@@ -772,6 +837,13 @@ const visibleCategoryRows = computed<CategoryTreeRow[]>(() => {
|
||||
})
|
||||
const filteredBrands = computed(() => filterBrandsByProductType(brands.value, form.type))
|
||||
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 modelDisplayName = computed(() => {
|
||||
if (modelFile.value) return modelFile.value.name
|
||||
@@ -795,13 +867,13 @@ const mediaPickerTitle = computed(() => {
|
||||
const mediaPickerDescription = computed(() => {
|
||||
switch (mediaPickerKind.value) {
|
||||
case 'gallery':
|
||||
return 'میتوانید چند تصویر را از Media Library انتخاب کنید یا در همان مودال فایل جدید آپلود کنید.'
|
||||
return 'تصاویر گالری را از Media Library انتخاب کنید.'
|
||||
case 'model':
|
||||
return 'یک فایل مدل سهبعدی را انتخاب کنید.'
|
||||
case 'share':
|
||||
return 'برای share image بهتر است از Media Library استفاده شود.'
|
||||
default:
|
||||
return 'یک تصویر را از Media Library انتخاب کنید یا مستقیماً از همان مودال آپلود کنید.'
|
||||
return 'رسانه موردنظر را از Media Library انتخاب کنید.'
|
||||
}
|
||||
})
|
||||
const mediaPickerAllowedSections = computed<MediaSection[]>(() => {
|
||||
@@ -947,44 +1019,6 @@ const mergeGalleryAssets = (items: GalleryAsset[]) => {
|
||||
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) => {
|
||||
mediaPickerKind.value = kind
|
||||
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 normalized = normalizeProductSlug(slug)
|
||||
if (!normalized) {
|
||||
@@ -1056,7 +1167,10 @@ const checkSlugAvailability = async (slug: string) => {
|
||||
|
||||
slugCheckState.value = 'checking'
|
||||
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'
|
||||
} catch {
|
||||
slugCheckState.value = 'idle'
|
||||
@@ -1113,7 +1227,10 @@ const submitCreateAttribute = async () => {
|
||||
if (emptyRow) {
|
||||
emptyRow.attributeId = created.id
|
||||
} else {
|
||||
attributeRows.value.push({ ...createEmptyAttributeRow(), attributeId: created.id })
|
||||
attributeRows.value = normalizeAttributeDisplayOrders([
|
||||
...attributeRows.value,
|
||||
{ ...createEmptyAttributeRow(attributeRows.value.length), attributeId: created.id },
|
||||
])
|
||||
}
|
||||
closeCreateAttributeModal()
|
||||
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 buildAttributeAssignments = (): ProductAttributeAssignment[] => {
|
||||
return attributeRows.value
|
||||
return normalizeAttributeDisplayOrders(attributeRows.value)
|
||||
.filter((row) => row.attributeId)
|
||||
.map((row) => {
|
||||
const attribute = getReusableAttribute(row.attributeId)
|
||||
const base: ProductAttributeAssignment = {
|
||||
attributeId: row.attributeId,
|
||||
displayOrder: row.displayOrder,
|
||||
overrideUnit: row.overrideUnit.trim() || undefined,
|
||||
name: attribute?.name,
|
||||
slug: attribute?.slug,
|
||||
dataType: attribute?.dataType,
|
||||
isVisible: attribute?.isVisible,
|
||||
isFilterable: attribute?.isFilterable,
|
||||
}
|
||||
|
||||
if (!attribute) return base
|
||||
@@ -1194,13 +1317,13 @@ const validateAttributeRows = () => {
|
||||
|
||||
const validateForm = () => {
|
||||
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.slug.trim()) errors.push('Slug الزامی است')
|
||||
if (form.slug.trim().length > MAX_SLUG_LENGTH) errors.push('Slug نمیتواند بیشتر از 16 کاراکتر باشد')
|
||||
if (slugCheckState.value === 'taken') errors.push('این اسلاگ قبلاً ثبت شده است')
|
||||
if (!form.technicalCode.trim()) errors.push('Technical code الزامی است')
|
||||
if (!form.brandId && !form.brand.trim()) errors.push('برند الزامی است')
|
||||
if (props.mode === 'edit' && !form.technicalCode.trim()) errors.push('Technical code الزامی است')
|
||||
if (props.mode === 'edit' && !form.brandId && !form.brand.trim()) errors.push('برند الزامی است')
|
||||
if (!form.categoryId) errors.push('دستهبندی محصول الزامی است')
|
||||
if (Number(form.basePriceUSD) < 0) errors.push('قیمت اصلی نمیتواند منفی باشد')
|
||||
if (form.salePriceUSD !== null && Number(form.salePriceUSD) < 0) errors.push('قیمت فروش نمیتواند منفی باشد')
|
||||
@@ -1236,7 +1359,7 @@ const resetForm = () => {
|
||||
slugGeneratedOnce.value = false
|
||||
slugCheckState.value = 'idle'
|
||||
tagInput.value = ''
|
||||
attributeRows.value = [createEmptyAttributeRow()]
|
||||
attributeRows.value = [createEmptyAttributeRow(0)]
|
||||
validationErrors.value = []
|
||||
errorMessage.value = ''
|
||||
slugTouched.value = false
|
||||
@@ -1251,8 +1374,6 @@ const submitForm = async () => {
|
||||
sku: form.sku.trim(),
|
||||
title: form.title.trim(),
|
||||
slug: normalizeProductSlug(form.slug.trim()),
|
||||
summary: form.meta.shortDescription.trim(),
|
||||
description: form.meta.description.trim(),
|
||||
meta: {
|
||||
...form.meta,
|
||||
shortDescription: form.meta.shortDescription.trim(),
|
||||
@@ -1272,7 +1393,8 @@ const submitForm = async () => {
|
||||
featured: form.featured,
|
||||
type: form.type,
|
||||
status: form.status,
|
||||
categoryId: form.categoryId || undefined,
|
||||
primaryCategoryId: form.categoryId || undefined,
|
||||
categoryIds: form.categoryId ? [form.categoryId] : [],
|
||||
tags: [...form.tags],
|
||||
attributes: buildAttributeAssignments(),
|
||||
existingMainImageUrl: form.existingMainImageUrl || undefined,
|
||||
@@ -1285,7 +1407,10 @@ const submitForm = async () => {
|
||||
|
||||
isSubmitting.value = true
|
||||
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 })
|
||||
router.push(`/admin/products/${response.id}`)
|
||||
} catch (error) {
|
||||
@@ -1349,6 +1474,13 @@ watch(
|
||||
|
||||
onMounted(async () => {
|
||||
await loadBootData()
|
||||
if (!errorMessage.value) {
|
||||
try {
|
||||
await loadProduct()
|
||||
} catch (error) {
|
||||
errorMessage.value = extractApiErrorMessage(error, 'بارگذاری اطلاعات محصول انجام نشد')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
<span class="text-xs text-white-dark">ویژگیها از API خوانده میشوند و هر ردیف فقط یک ویژگی را مدیریت میکند.</span>
|
||||
</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]">
|
||||
<span class="inline-flex h-8 w-8 animate-spin rounded-full border-4 border-primary border-l-transparent"></span>
|
||||
</div>
|
||||
@@ -18,94 +22,123 @@
|
||||
<div
|
||||
v-for="(row, index) in rows"
|
||||
: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">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-black dark:text-white">ویژگی {{ index + 1 }}</div>
|
||||
<div class="text-xs text-white-dark">{{ getAttribute(row.attributeId)?.slug || 'ویژگی انتخاب نشده' }}</div>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
@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>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" @click="removeRow(index)">حذف</button>
|
||||
</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 class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_180px]">
|
||||
<div>
|
||||
<label class="mb-2 block">ویژگی reusable</label>
|
||||
<select v-model="row.attributeId" class="form-select" @change="onAttributeChange(row)">
|
||||
<option value="">انتخاب ویژگی</option>
|
||||
<option v-for="attribute in reusableAttributes" :key="attribute.id" :value="attribute.id">
|
||||
{{ attribute.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block">واحد جایگزین</label>
|
||||
<input v-model.trim="row.overrideUnit" type="text" class="form-input" :placeholder="getAttribute(row.attributeId)?.unit || 'مثلاً mm'" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="getAttribute(row.attributeId)" class="mt-4 space-y-4">
|
||||
<div class="flex flex-wrap gap-2 text-xs">
|
||||
<span class="badge bg-primary/15 text-primary">{{ dataTypeLabelMap[getAttribute(row.attributeId)!.dataType] }}</span>
|
||||
<span v-if="getAttribute(row.attributeId)?.unit" class="badge bg-success/15 text-success">{{ getAttribute(row.attributeId)?.unit }}</span>
|
||||
<span v-if="getAttribute(row.attributeId)?.isFilterable" class="badge bg-info/15 text-info">فیلترپذیر</span>
|
||||
<span v-if="getAttribute(row.attributeId)?.isVisible" class="badge bg-warning/15 text-warning">قابل نمایش</span>
|
||||
</div>
|
||||
|
||||
<div v-if="getAttribute(row.attributeId)?.dataType === 'text'">
|
||||
<label class="mb-2 block">مقدار</label>
|
||||
<input v-model.trim="row.valueText" type="text" class="form-input" placeholder="مقدار متنی" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="getAttribute(row.attributeId)?.dataType === 'number'">
|
||||
<label class="mb-2 block">مقدار عددی</label>
|
||||
<input v-model="row.valueNumber" type="number" step="0.01" class="form-input" placeholder="0" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="getAttribute(row.attributeId)?.dataType === 'boolean'" class="flex items-center gap-3 rounded-xl border border-white-light px-4 py-3 dark:border-[#1b2e4b]">
|
||||
<input :id="`attribute-bool-${row.id}`" v-model="row.valueBoolean" type="checkbox" class="form-checkbox outline-primary" />
|
||||
<label :for="`attribute-bool-${row.id}`" class="!mb-0">فعال / صحیح</label>
|
||||
</div>
|
||||
|
||||
<div v-else-if="getAttribute(row.attributeId)?.dataType === 'select'">
|
||||
<label class="mb-2 block">انتخاب مقدار</label>
|
||||
<select v-model="row.valueText" class="form-select">
|
||||
<option value="">یک گزینه را انتخاب کنید</option>
|
||||
<option v-for="option in getAttribute(row.attributeId)?.options || []" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-else-if="getAttribute(row.attributeId)?.dataType === 'multiselect'">
|
||||
<label class="mb-2 block">انتخاب چندتایی</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<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
|
||||
v-for="option in getAttribute(row.attributeId)?.options || []"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="rounded-xl border px-3 py-2 text-sm transition"
|
||||
:class="row.valueMultiText.includes(option.value) ? 'border-primary bg-primary/10 text-primary' : 'border-white-light text-white-dark dark:border-[#1b2e4b]'"
|
||||
@click="toggleMultiValue(row, option.value)"
|
||||
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)"
|
||||
>
|
||||
{{ option.label }}
|
||||
<IconTrash class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="getAttribute(row.attributeId)?.dataType === 'json'">
|
||||
<label class="mb-2 block">مقدار JSON</label>
|
||||
<textarea
|
||||
v-model.trim="row.valueJson"
|
||||
rows="5"
|
||||
class="form-textarea font-mono text-sm"
|
||||
placeholder='{"key":"value"}'
|
||||
></textarea>
|
||||
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_180px]">
|
||||
<div>
|
||||
<label class="mb-2 block">ویژگی reusable</label>
|
||||
<select v-model="row.attributeId" class="form-select" @change="onAttributeChange(row)">
|
||||
<option value="">انتخاب ویژگی</option>
|
||||
<option v-for="attribute in reusableAttributes" :key="attribute.id" :value="attribute.id">
|
||||
{{ attribute.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block">واحد جایگزین</label>
|
||||
<input v-model.trim="row.overrideUnit" type="text" class="form-input" :placeholder="getAttribute(row.attributeId)?.unit || 'مثلاً mm'" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 rounded-xl border border-dashed border-white-light px-4 py-4 text-sm text-white-dark dark:border-[#1b2e4b]">
|
||||
ابتدا ویژگی reusable را انتخاب کنید.
|
||||
<div v-if="getAttribute(row.attributeId)" class="mt-4 space-y-4">
|
||||
<div class="flex flex-wrap gap-2 text-xs">
|
||||
<span class="badge bg-primary/15 text-primary">{{ dataTypeLabelMap[getAttribute(row.attributeId)!.dataType] }}</span>
|
||||
<span v-if="getAttribute(row.attributeId)?.unit" class="badge bg-success/15 text-success">{{ getAttribute(row.attributeId)?.unit }}</span>
|
||||
<span v-if="getAttribute(row.attributeId)?.isFilterable" class="badge bg-info/15 text-info">فیلترپذیر</span>
|
||||
<span v-if="getAttribute(row.attributeId)?.isVisible" class="badge bg-warning/15 text-warning">قابل نمایش</span>
|
||||
</div>
|
||||
|
||||
<div v-if="getAttribute(row.attributeId)?.dataType === 'text'">
|
||||
<label class="mb-2 block">مقدار</label>
|
||||
<input v-model.trim="row.valueText" type="text" class="form-input" placeholder="مقدار متنی" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="getAttribute(row.attributeId)?.dataType === 'number'">
|
||||
<label class="mb-2 block">مقدار عددی</label>
|
||||
<input v-model="row.valueNumber" type="number" step="0.01" class="form-input" placeholder="0" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="getAttribute(row.attributeId)?.dataType === 'boolean'" class="flex items-center gap-3 rounded-xl border border-white-light px-4 py-3 dark:border-[#1b2e4b]">
|
||||
<input :id="`attribute-bool-${row.id}`" v-model="row.valueBoolean" type="checkbox" class="form-checkbox outline-primary" />
|
||||
<label :for="`attribute-bool-${row.id}`" class="!mb-0">فعال / صحیح</label>
|
||||
</div>
|
||||
|
||||
<div v-else-if="getAttribute(row.attributeId)?.dataType === 'select'">
|
||||
<label class="mb-2 block">انتخاب مقدار</label>
|
||||
<select v-model="row.valueText" class="form-select">
|
||||
<option value="">یک گزینه را انتخاب کنید</option>
|
||||
<option v-for="option in getAttribute(row.attributeId)?.options || []" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-else-if="getAttribute(row.attributeId)?.dataType === 'multiselect'">
|
||||
<label class="mb-2 block">انتخاب چندتایی</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="option in getAttribute(row.attributeId)?.options || []"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="rounded-xl border px-3 py-2 text-sm transition"
|
||||
:class="row.valueMultiText.includes(option.value) ? 'border-primary bg-primary/10 text-primary' : 'border-white-light text-white-dark dark:border-[#1b2e4b]'"
|
||||
@click="toggleMultiValue(row, option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="getAttribute(row.attributeId)?.dataType === 'json'">
|
||||
<label class="mb-2 block">مقدار JSON</label>
|
||||
<textarea
|
||||
v-model.trim="row.valueJson"
|
||||
rows="5"
|
||||
class="form-textarea font-mono text-sm"
|
||||
placeholder='{"key":"value"}'
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 rounded-xl border border-dashed border-white-light px-4 py-4 text-sm text-white-dark dark:border-[#1b2e4b]">
|
||||
ابتدا ویژگی reusable را انتخاب کنید.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,6 +146,9 @@
|
||||
</template>
|
||||
|
||||
<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 { ProductAttributeFormRow } from '@/types/admin-product-create'
|
||||
|
||||
@@ -127,6 +163,8 @@ defineEmits<{
|
||||
(event: 'request-create'): void
|
||||
}>()
|
||||
|
||||
const openRowId = ref('')
|
||||
|
||||
const dataTypeLabelMap = {
|
||||
text: 'متنی',
|
||||
number: 'عددی',
|
||||
@@ -136,9 +174,26 @@ const dataTypeLabelMap = {
|
||||
json: 'JSON',
|
||||
} 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 => ({
|
||||
id: crypto.randomUUID(),
|
||||
attributeId: '',
|
||||
displayOrder: rows.value.length,
|
||||
valueText: '',
|
||||
valueNumber: '',
|
||||
valueBoolean: false,
|
||||
@@ -147,16 +202,56 @@ const createEmptyRow = (): ProductAttributeFormRow => ({
|
||||
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 = () => {
|
||||
rows.value.push(createEmptyRow())
|
||||
const nextRow = createEmptyRow()
|
||||
rows.value.push(nextRow)
|
||||
syncDisplayOrders()
|
||||
openRowId.value = nextRow.id
|
||||
}
|
||||
|
||||
const removeRow = (index: number) => {
|
||||
const removed = rows.value[index]
|
||||
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) => {
|
||||
return props.reusableAttributes.find((attribute) => attribute.id === attributeId)
|
||||
const moveRow = (index: number, direction: -1 | 1) => {
|
||||
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) => {
|
||||
@@ -169,6 +264,7 @@ const resetRowValues = (row: ProductAttributeFormRow) => {
|
||||
|
||||
const onAttributeChange = (row: ProductAttributeFormRow) => {
|
||||
resetRowValues(row)
|
||||
openRowId.value = row.id
|
||||
}
|
||||
|
||||
const toggleMultiValue = (row: ProductAttributeFormRow, value: string) => {
|
||||
@@ -179,4 +275,23 @@ const toggleMultiValue = (row: ProductAttributeFormRow, value: string) => {
|
||||
|
||||
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>
|
||||
|
||||
@@ -537,7 +537,7 @@ const validateForm = () => {
|
||||
if (!form.sku.trim()) return 'SKU الزامی است'
|
||||
if (!form.slug.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.salePriceUSD !== null && form.salePriceUSD !== undefined && form.salePriceUSD !== '' && Number(form.salePriceUSD) < 0) {
|
||||
return 'قیمت فروش نمی تواند منفی باشد'
|
||||
|
||||
@@ -249,6 +249,7 @@ const menuSections: MenuSection[] = [
|
||||
children: [
|
||||
{ label: 'تنظیمات عمومی', to: '/admin/settings' },
|
||||
{ label: 'درگاه های پرداخت', to: '/admin/settings/payment-gateways' },
|
||||
{ label: 'فیش های بانکی ارسالی', to: '/admin/payments/bank-slips' },
|
||||
{ label: 'روش های ارسال', to: '/admin/settings/shipping-methods' },
|
||||
{ label: 'تنظیمات سئو', to: '/admin/settings/seo' },
|
||||
],
|
||||
|
||||
14
src/config/api.ts
Normal file
14
src/config/api.ts
Normal 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}`)
|
||||
@@ -168,7 +168,24 @@ const router = createRouter({
|
||||
createAdminPlaceholderRoute('support/product-questions', 'AdminSupportProductQuestions', 'سوالات محصولات', 'مدیریت پرسش و پاسخ های ثبت شده برای کالاها'),
|
||||
createAdminPlaceholderRoute('users/roles', 'AdminUsersRoles', 'نقش ها و دسترسی ها', 'مدیریت نقش های سازمانی و سطح دسترسی کاربران پنل'),
|
||||
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/seo', 'AdminSettingsSeo', 'تنظیمات سئو', 'مدیریت تنظیمات فنی و محتوایی سئو فروشگاه'),
|
||||
{
|
||||
|
||||
@@ -82,14 +82,14 @@ export const buildProductFormData = (payload: ProductFormPayload): FormData => {
|
||||
formData.append('sku', payload.sku)
|
||||
formData.append('title', payload.title)
|
||||
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('technicalCode', payload.technicalCode)
|
||||
if (payload.brandId !== undefined) {
|
||||
formData.append('brandId', payload.brandId)
|
||||
}
|
||||
formData.append('brand', payload.brand)
|
||||
if (payload.brand) {
|
||||
formData.append('brand', payload.brand)
|
||||
}
|
||||
formData.append('basePriceUSD', String(payload.basePriceUSD))
|
||||
formData.append('stock', String(payload.stock))
|
||||
formData.append('featured', String(payload.featured))
|
||||
@@ -97,14 +97,15 @@ export const buildProductFormData = (payload: ProductFormPayload): FormData => {
|
||||
formData.append('status', payload.status)
|
||||
formData.append('attributes', JSON.stringify(payload.attributes || []))
|
||||
formData.append('tags', JSON.stringify(payload.tags || []))
|
||||
formData.append('categoryIds', JSON.stringify(payload.categoryIds || []))
|
||||
formData.append('existingGalleryUrls', JSON.stringify(payload.existingGalleryUrls || []))
|
||||
|
||||
if (payload.salePriceUSD !== undefined && payload.salePriceUSD !== null) {
|
||||
formData.append('salePriceUSD', String(payload.salePriceUSD))
|
||||
}
|
||||
|
||||
if (payload.categoryId) {
|
||||
formData.append('categoryId', payload.categoryId)
|
||||
if (payload.primaryCategoryId) {
|
||||
formData.append('primaryCategoryId', payload.primaryCategoryId)
|
||||
}
|
||||
|
||||
if (payload.existingMainImageUrl !== undefined) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
import { API_BASE_URL } from '@/config/api'
|
||||
import type {
|
||||
ApiResponse,
|
||||
AuthResponse,
|
||||
@@ -17,7 +18,7 @@ class ApiService {
|
||||
|
||||
constructor() {
|
||||
this.api = axios.create({
|
||||
baseURL: '/api', // استفاده از proxy
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
|
||||
30
src/services/payment-service.ts
Normal file
30
src/services/payment-service.ts
Normal 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
|
||||
15
src/services/settings-service.ts
Normal file
15
src/services/settings-service.ts
Normal 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
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface ProductAttributeFormRow {
|
||||
id: string
|
||||
attributeId: string
|
||||
displayOrder: number
|
||||
valueText: string
|
||||
valueNumber: string
|
||||
valueBoolean: boolean
|
||||
|
||||
100
src/types/payment.ts
Normal file
100
src/types/payment.ts
Normal 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
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export interface ProductAttributeOption {
|
||||
|
||||
export interface ProductAttributeAssignment {
|
||||
attributeId?: string
|
||||
displayOrder?: number
|
||||
name?: string
|
||||
slug?: string
|
||||
dataType?: ProductAttributeDataType
|
||||
@@ -202,12 +203,12 @@ export interface ProductFormPayload {
|
||||
sku: string
|
||||
title: string
|
||||
slug: string
|
||||
summary: string
|
||||
description: string
|
||||
summary?: string
|
||||
description?: string
|
||||
meta?: ProductMeta
|
||||
technicalCode: string
|
||||
brandId?: string
|
||||
brand: string
|
||||
brand?: string
|
||||
basePriceUSD: number
|
||||
salePriceUSD?: number | null | ''
|
||||
stock: number
|
||||
@@ -215,6 +216,8 @@ export interface ProductFormPayload {
|
||||
type: ProductType
|
||||
status: ProductStatus
|
||||
categoryId?: string
|
||||
primaryCategoryId?: string
|
||||
categoryIds?: string[]
|
||||
attributes: ProductAttributes | ProductAttributeAssignment[]
|
||||
tags: string[]
|
||||
existingMainImageUrl?: string
|
||||
|
||||
14
src/types/settings.ts
Normal file
14
src/types/settings.ts
Normal 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
|
||||
}
|
||||
160
src/views/admin/AdminPaymentBankSlips.vue
Normal file
160
src/views/admin/AdminPaymentBankSlips.vue
Normal 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>
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<ProductForm mode="edit" :product-id="String(route.params.id)" />
|
||||
<AdminProductCreateForm mode="edit" :product-id="String(route.params.id || '')" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
@@ -1,8 +1,227 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-black dark:text-white mb-8">تنظیمات سیستم</h1>
|
||||
<div class="panel">
|
||||
<p class="text-white-dark">تنظیمات پنل مدیریت</p>
|
||||
<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>
|
||||
<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="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>
|
||||
</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>
|
||||
|
||||
378
src/views/admin/AdminSettingsPaymentGateways.vue
Normal file
378
src/views/admin/AdminSettingsPaymentGateways.vue
Normal 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>
|
||||
@@ -11,13 +11,7 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
secure: false
|
||||
}
|
||||
}
|
||||
host: '0.0.0.0',
|
||||
port: 5173
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user