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="space-y-2">
|
||||||
<div class="inline-flex items-center gap-2 rounded-full border border-white-light bg-white-light/40 px-3 py-1 text-xs font-semibold text-primary dark:border-[#1b2e4b] dark:bg-[#060818]">
|
<div class="inline-flex items-center gap-2 rounded-full border border-white-light bg-white-light/40 px-3 py-1 text-xs font-semibold text-primary dark:border-[#1b2e4b] dark:bg-[#060818]">
|
||||||
<span class="h-2 w-2 rounded-full bg-primary"></span>
|
<span class="h-2 w-2 rounded-full bg-primary"></span>
|
||||||
ایجاد محصول
|
{{ pageBadgeLabel }}
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-3xl font-bold tracking-tight text-black dark:text-white">افزودن محصول جدید</h1>
|
<h1 class="text-3xl font-bold tracking-tight text-black dark:text-white">{{ pageTitle }}</h1>
|
||||||
<p class="max-w-3xl text-sm leading-7 text-white-dark">
|
<p class="max-w-3xl text-sm leading-7 text-white-dark">
|
||||||
فرم به چند باکس مشخص تقسیم شده تا اطلاعات پایه، محتوا، سئو، رسانه و ویژگیها از هم جدا باشند و صفحه شلوغ نشود.
|
{{ pageDescription }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<router-link to="/admin/products" class="btn btn-outline-secondary">بازگشت به لیست</router-link>
|
<router-link to="/admin/products" class="btn btn-outline-secondary">بازگشت به لیست</router-link>
|
||||||
<button type="submit" form="create-product-form" class="btn btn-primary" :disabled="isSubmitting || isBootLoading">
|
<button type="submit" form="create-product-form" class="btn btn-primary" :disabled="isSubmitting || isBootLoading">
|
||||||
<span v-if="isSubmitting">در حال ذخیره...</span>
|
<span v-if="isSubmitting">در حال ذخیره...</span>
|
||||||
<span v-else>ذخیره محصول</span>
|
<span v-else>{{ props.mode === 'edit' ? 'ذخیره تغییرات' : 'ذخیره محصول' }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,52 +121,90 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="baseInfoTab === 'categories'" class="grid grid-cols-1 gap-5 xl:grid-cols-[220px_minmax(0,1fr)_260px]">
|
<div v-else-if="baseInfoTab === 'categories'" class="grid grid-cols-1 gap-5 xl:grid-cols-[240px_minmax(0,1fr)_280px]">
|
||||||
<div>
|
<div class="rounded-[24px] border border-primary/15 bg-gradient-to-br from-primary/10 via-white to-white px-5 py-5 shadow-sm dark:border-primary/20 dark:from-primary/10 dark:via-[#0e1726] dark:to-[#0b1321]">
|
||||||
<label for="product-type">نوع محصول</label>
|
<label for="product-type" class="text-sm font-semibold text-black dark:text-white">نوع محصول</label>
|
||||||
<select id="product-type" v-model="form.type" class="form-select">
|
<p class="mt-1 text-xs leading-6 text-white-dark">با انتخاب نوع، فقط دستهبندیها و برندهای مرتبط نمایش داده میشوند.</p>
|
||||||
|
<select id="product-type" v-model="form.type" class="form-select mt-4 rounded-[16px] border-white bg-white/90 shadow-sm dark:border-[#1b2e4b] dark:bg-[#0b1321]">
|
||||||
<option v-for="item in productTypeOptions" :key="item.value" :value="item.value">{{ item.label }}</option>
|
<option v-for="item in productTypeOptions" :key="item.value" :value="item.value">{{ item.label }}</option>
|
||||||
</select>
|
</select>
|
||||||
<div class="mb-2 flex items-center justify-between gap-3">
|
<div class="mt-4 rounded-[18px] border border-primary/15 bg-white/80 px-4 py-3 text-sm shadow-sm dark:border-primary/20 dark:bg-[#0f1b30]/80">
|
||||||
<span class="text-xs text-white-dark">نمای درختی</span>
|
<div class="text-xs text-white-dark">نوع انتخابشده</div>
|
||||||
<button v-if="form.categoryId" type="button" class="text-xs text-danger hover:underline" @click="form.categoryId = ''">پاک کردن انتخاب</button>
|
<div class="mt-1 font-semibold text-black dark:text-white">{{ selectedProductTypeLabel }}</div>
|
||||||
|
<div class="mt-2 text-[11px] leading-5 text-white-dark">{{ filteredCategories.length }} دستهبندی و {{ filteredBrands.length }} برند برای این نوع در دسترس است.</div>
|
||||||
</div>
|
</div>
|
||||||
<input id="product-category-search" v-model.trim="categorySearch" type="text" class="form-input" placeholder="جستجو در دسته‌بندی‌ها..." />
|
</div>
|
||||||
<div class="mt-3 max-h-[340px] overflow-y-auto rounded-[22px] border border-white-light px-3 py-3 dark:border-[#1b2e4b]">
|
|
||||||
<div v-if="visibleCategoryRows.length" class="space-y-1">
|
<div class="overflow-hidden rounded-[24px] border border-white-light/80 bg-white shadow-sm dark:border-[#1b2e4b] dark:bg-[#0b1321]">
|
||||||
|
<div class="flex flex-col gap-4 border-b border-white-light/80 bg-gradient-to-r from-white to-primary/5 px-5 py-5 dark:border-[#1b2e4b] dark:from-[#0b1321] dark:to-primary/10 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<label for="product-category-search" class="text-sm font-semibold text-black dark:text-white">دستهبندی محصولات</label>
|
||||||
|
<p class="mt-1 text-xs leading-6 text-white-dark">از میان ساختار درختی دستهها، مناسبترین گزینه نهایی را برای محصول انتخاب کنید.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="form.categoryId"
|
||||||
|
type="button"
|
||||||
|
class="inline-flex shrink-0 items-center justify-center rounded-full border border-danger/20 bg-danger/5 px-3 py-2 text-xs font-semibold text-danger transition hover:bg-danger/10"
|
||||||
|
@click="form.categoryId = ''"
|
||||||
|
>
|
||||||
|
پاک کردن انتخاب
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-5 py-5">
|
||||||
|
<input
|
||||||
|
id="product-category-search"
|
||||||
|
v-model.trim="categorySearch"
|
||||||
|
type="text"
|
||||||
|
class="form-input rounded-[16px] border-white bg-white-light/50 shadow-sm dark:border-[#1b2e4b] dark:bg-[#060818]"
|
||||||
|
placeholder="جستجو در دستهبندیها..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="mt-4 max-h-[340px] overflow-y-auto rounded-[20px] border border-white-light/80 bg-white-light/20 px-3 py-3 dark:border-[#1b2e4b] dark:bg-[#060818]/70">
|
||||||
|
<div v-if="visibleCategoryRows.length" class="space-y-2">
|
||||||
<label
|
<label
|
||||||
v-for="row in visibleCategoryRows"
|
v-for="row in visibleCategoryRows"
|
||||||
:key="row.id"
|
:key="row.id"
|
||||||
class="flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2 transition hover:bg-white-light/40 dark:hover:bg-[#060818]"
|
class="group flex cursor-pointer items-center gap-3 rounded-[18px] border border-transparent bg-white/80 px-3 py-3 shadow-sm transition hover:border-primary/20 hover:bg-primary/5 dark:bg-[#0b1321]/80 dark:hover:bg-primary/10"
|
||||||
|
:class="isCategoryChecked(row.id) ? 'border-primary/30 bg-primary/5 dark:bg-primary/10' : ''"
|
||||||
:style="{ paddingRight: `${0.75 + row.level * 1.25}rem` }"
|
:style="{ paddingRight: `${0.75 + row.level * 1.25}rem` }"
|
||||||
>
|
>
|
||||||
<input type="checkbox" class="form-checkbox outline-primary" :checked="isCategoryChecked(row.id)" @change="toggleCategorySelection(row.id)" />
|
<input type="checkbox" class="form-checkbox outline-primary" :checked="isCategoryChecked(row.id)" @change="toggleCategorySelection(row.id)" />
|
||||||
<span class="min-w-0 flex-1">
|
<span class="min-w-0 flex-1">
|
||||||
<span class="block truncate text-sm font-semibold text-black dark:text-white">{{ row.name }}</span>
|
<span class="block truncate text-sm font-semibold text-black dark:text-white">{{ row.name }}</span>
|
||||||
<span class="text-[11px] text-white-dark">{{ row.slug }}</span>
|
<span class="mt-1 block text-[11px] text-white-dark">{{ row.slug }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
<span class="rounded-full bg-white-light px-2 py-1 text-[10px] font-semibold text-white-dark dark:bg-[#111c33]">سطح {{ row.level + 1 }}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="rounded-xl border border-dashed border-white-light px-4 py-6 text-center text-sm text-white-dark dark:border-[#1b2e4b]">
|
<div v-else class="rounded-[18px] border border-dashed border-white-light px-4 py-8 text-center text-sm text-white-dark dark:border-[#1b2e4b]">
|
||||||
دسته‌بندی مطابق جستجو پیدا نشد.
|
دستهبندی مطابق جستجو پیدا نشد.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="selectedCategoryLabel" class="mt-2 text-xs text-success">انتخاب ÙØ¹Ù„ÛŒ: {{ selectedCategoryLabel }}</p>
|
|
||||||
|
<div class="mt-4 rounded-[18px] border border-success/20 bg-success/5 px-4 py-4 dark:border-success/20">
|
||||||
|
<div class="text-xs text-white-dark">انتخاب فعلی</div>
|
||||||
|
<div class="mt-1 text-sm font-semibold text-black dark:text-white">{{ selectedCategoryLabel || 'هنوز دستهبندی انتخاب نشده است' }}</div>
|
||||||
|
<div v-if="selectedCategoryTrail.length" class="mt-2 text-[11px] leading-6 text-white-dark">
|
||||||
|
مسیر: {{ selectedCategoryTrail.join(' / ') }}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label for="product-category">دستهبندی</label>
|
|
||||||
<select id="product-category" v-model="form.categoryId" class="hidden">
|
|
||||||
<option value="">یک دستهبندی انتخاب کنید</option>
|
|
||||||
<option v-for="category in filteredCategories" :key="category.id" :value="category.id">{{ category.name }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<label for="product-brand">برند</label>
|
</div>
|
||||||
<select id="product-brand" v-model="form.brandId" class="form-select">
|
|
||||||
|
<div class="rounded-[24px] border border-white-light/80 bg-gradient-to-b from-white to-white-light/30 px-5 py-5 shadow-sm dark:border-[#1b2e4b] dark:from-[#0b1321] dark:to-[#09111e]">
|
||||||
|
<label for="product-brand" class="text-sm font-semibold text-black dark:text-white">برند</label>
|
||||||
|
<p class="mt-1 text-xs leading-6 text-white-dark">برند محصول را از بین موارد سازگار با نوع انتخابشده مشخص کنید.</p>
|
||||||
|
<select id="product-brand" v-model="form.brandId" class="form-select mt-4 rounded-[16px] border-white bg-white/90 shadow-sm dark:border-[#1b2e4b] dark:bg-[#060818]">
|
||||||
<option value="">یک برند انتخاب کنید</option>
|
<option value="">یک برند انتخاب کنید</option>
|
||||||
<option v-for="brand in filteredBrands" :key="brand.id" :value="brand.id">{{ brand.name }}</option>
|
<option v-for="brand in filteredBrands" :key="brand.id" :value="brand.id">{{ brand.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
<p v-if="form.brand && !form.brandId" class="mt-2 text-xs text-warning">Fallback brand: {{ form.brand }}</p>
|
|
||||||
|
<div class="mt-4 rounded-[18px] border border-white-light bg-white/80 px-4 py-4 text-sm shadow-sm dark:border-[#1b2e4b] dark:bg-[#060818]">
|
||||||
|
<div class="text-xs text-white-dark">وضعیت برند</div>
|
||||||
|
<div class="mt-1 font-semibold text-black dark:text-white">{{ selectedBrand?.name || 'برندی انتخاب نشده است' }}</div>
|
||||||
|
<div v-if="form.brand && !form.brandId" class="mt-2 text-[11px] leading-5 text-warning">مقدار جایگزین فعلی: {{ form.brand }}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -260,7 +298,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</AdminFormSection>
|
</AdminFormSection>
|
||||||
|
|
||||||
<AdminFormSection title="رسانهها" description="برای هر رسانه میتوانید فایل جدید آپلود کنید یا از Media Library انتخاب کنید.">
|
<AdminFormSection title="رسانهها" description="برای هر رسانه، فایل را فقط از طریق Media Library انتخاب کنید.">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<IconGallery class="h-5 w-5" />
|
<IconGallery class="h-5 w-5" />
|
||||||
</template>
|
</template>
|
||||||
@@ -281,17 +319,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-4 flex flex-wrap gap-2">
|
<div class="mt-4 flex flex-wrap gap-2">
|
||||||
<button type="button" class="btn btn-outline-primary btn-sm" @click="openMediaPicker('model')">انتخاب از Media Library</button>
|
<button type="button" class="btn btn-outline-primary btn-sm" @click="openMediaPicker('model')">انتخاب از Media Library</button>
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm" @click="modelInput?.click()">آپلود فایل جدید</button>
|
|
||||||
<button v-if="modelDisplayName" type="button" class="btn btn-outline-danger btn-sm" @click="clearModelFile">حذف</button>
|
<button v-if="modelDisplayName" type="button" class="btn btn-outline-danger btn-sm" @click="clearModelFile">حذف</button>
|
||||||
</div>
|
</div>
|
||||||
<input ref="modelInput" type="file" accept=".glb,.gltf,.obj,.usdz,.zip,model/*" class="hidden" @change="onModelSelected" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-[22px] border border-white-light p-4 dark:border-[#1b2e4b] xl:col-span-2">
|
<div class="rounded-[22px] border border-white-light p-4 dark:border-[#1b2e4b] xl:col-span-2">
|
||||||
<div class="mb-4 flex items-center justify-between gap-3">
|
<div class="mb-4 flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div class="font-semibold text-black dark:text-white">گالری تصاویر</div>
|
<div class="font-semibold text-black dark:text-white">گالری تصاویر</div>
|
||||||
<div class="text-xs text-white-dark">میتوانید فایلهای آپلودی و آیتمهای Media Library را با هم ترکیب کنید</div>
|
<div class="text-xs text-white-dark">تصاویر گالری فقط از Media Library انتخاب میشوند</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-full border border-white-light px-3 py-1 text-xs text-white-dark dark:border-[#1b2e4b]">
|
<div class="rounded-full border border-white-light px-3 py-1 text-xs text-white-dark dark:border-[#1b2e4b]">
|
||||||
{{ galleryAssets.length }} آیتم
|
{{ galleryAssets.length }} آیتم
|
||||||
@@ -314,10 +350,8 @@
|
|||||||
|
|
||||||
<div class="mt-4 flex flex-wrap gap-2">
|
<div class="mt-4 flex flex-wrap gap-2">
|
||||||
<button type="button" class="btn btn-outline-primary btn-sm" @click="openMediaPicker('gallery')">انتخاب از Media Library</button>
|
<button type="button" class="btn btn-outline-primary btn-sm" @click="openMediaPicker('gallery')">انتخاب از Media Library</button>
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm" @click="galleryInput?.click()">آپلود چند فایل</button>
|
|
||||||
<button v-if="galleryAssets.length" type="button" class="btn btn-outline-danger btn-sm" @click="clearGalleryAssets">پاک کردن همه</button>
|
<button v-if="galleryAssets.length" type="button" class="btn btn-outline-danger btn-sm" @click="clearGalleryAssets">پاک کردن همه</button>
|
||||||
</div>
|
</div>
|
||||||
<input ref="galleryInput" type="file" accept="image/*" multiple class="hidden" @change="onGalleryFilesSelected" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AdminFormSection>
|
</AdminFormSection>
|
||||||
@@ -383,10 +417,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-4 flex flex-wrap gap-2">
|
<div class="mt-4 flex flex-wrap gap-2">
|
||||||
<button type="button" class="btn btn-outline-primary btn-sm" @click="openMediaPicker('main')">انتخاب از Media Library</button>
|
<button type="button" class="btn btn-outline-primary btn-sm" @click="openMediaPicker('main')">انتخاب از Media Library</button>
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm" @click="mainImageInput?.click()">آپلود فایل جدید</button>
|
|
||||||
<button v-if="mainImagePreview" type="button" class="btn btn-outline-danger btn-sm" @click="clearMainImage">حذف</button>
|
<button v-if="mainImagePreview" type="button" class="btn btn-outline-danger btn-sm" @click="clearMainImage">حذف</button>
|
||||||
</div>
|
</div>
|
||||||
<input ref="mainImageInput" type="file" accept="image/*" class="hidden" @change="onMainImageSelected" />
|
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
@@ -536,6 +568,7 @@ import type {
|
|||||||
ProductAttributeDataType,
|
ProductAttributeDataType,
|
||||||
ProductAttributeOption,
|
ProductAttributeOption,
|
||||||
ProductFormPayload,
|
ProductFormPayload,
|
||||||
|
ProductListItem,
|
||||||
ProductMeta,
|
ProductMeta,
|
||||||
ProductStatus,
|
ProductStatus,
|
||||||
ProductType,
|
ProductType,
|
||||||
@@ -546,6 +579,7 @@ import {
|
|||||||
filterCategoriesByType,
|
filterCategoriesByType,
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
generateEnglishSlug,
|
generateEnglishSlug,
|
||||||
|
mapProductToForm,
|
||||||
productStatusOptions,
|
productStatusOptions,
|
||||||
productTypeOptions,
|
productTypeOptions,
|
||||||
slugify,
|
slugify,
|
||||||
@@ -570,6 +604,17 @@ type CategoryTreeRow = {
|
|||||||
children: CategoryTreeRow[]
|
children: CategoryTreeRow[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
mode?: 'create' | 'edit'
|
||||||
|
productId?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
mode: 'create',
|
||||||
|
productId: '',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const MAX_SLUG_LENGTH = 16
|
const MAX_SLUG_LENGTH = 16
|
||||||
const SLUG_CHECK_DELAY = 600
|
const SLUG_CHECK_DELAY = 600
|
||||||
|
|
||||||
@@ -594,9 +639,10 @@ const createEmptyMeta = (): ProductMeta => ({
|
|||||||
shareImageUrl: '',
|
shareImageUrl: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const createEmptyAttributeRow = (): ProductAttributeFormRow => ({
|
const createEmptyAttributeRow = (displayOrder = 0): ProductAttributeFormRow => ({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
attributeId: '',
|
attributeId: '',
|
||||||
|
displayOrder,
|
||||||
valueText: '',
|
valueText: '',
|
||||||
valueNumber: '',
|
valueNumber: '',
|
||||||
valueBoolean: false,
|
valueBoolean: false,
|
||||||
@@ -605,6 +651,12 @@ const createEmptyAttributeRow = (): ProductAttributeFormRow => ({
|
|||||||
overrideUnit: '',
|
overrideUnit: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const normalizeAttributeDisplayOrders = (rows: ProductAttributeFormRow[]) =>
|
||||||
|
rows.map((row, index) => ({
|
||||||
|
...row,
|
||||||
|
displayOrder: index,
|
||||||
|
}))
|
||||||
|
|
||||||
const createEmptyReusableAttributeForm = () => ({
|
const createEmptyReusableAttributeForm = () => ({
|
||||||
name: '',
|
name: '',
|
||||||
slug: '',
|
slug: '',
|
||||||
@@ -642,7 +694,7 @@ const form = reactive({
|
|||||||
const categories = ref<Category[]>([])
|
const categories = ref<Category[]>([])
|
||||||
const brands = ref<Brand[]>([])
|
const brands = ref<Brand[]>([])
|
||||||
const reusableAttributes = ref<ReusableProductAttribute[]>([])
|
const reusableAttributes = ref<ReusableProductAttribute[]>([])
|
||||||
const attributeRows = ref<ProductAttributeFormRow[]>([createEmptyAttributeRow()])
|
const attributeRows = ref<ProductAttributeFormRow[]>([createEmptyAttributeRow(0)])
|
||||||
const tagInput = ref('')
|
const tagInput = ref('')
|
||||||
const validationErrors = ref<string[]>([])
|
const validationErrors = ref<string[]>([])
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
@@ -661,9 +713,6 @@ const isCreateAttributeModalOpen = ref(false)
|
|||||||
const isCreatingAttribute = ref(false)
|
const isCreatingAttribute = ref(false)
|
||||||
const createAttributeForm = reactive(createEmptyReusableAttributeForm())
|
const createAttributeForm = reactive(createEmptyReusableAttributeForm())
|
||||||
const categorySearch = ref('')
|
const categorySearch = ref('')
|
||||||
const mainImageInput = ref<HTMLInputElement | null>(null)
|
|
||||||
const galleryInput = ref<HTMLInputElement | null>(null)
|
|
||||||
const modelInput = ref<HTMLInputElement | null>(null)
|
|
||||||
const mainImageFile = ref<File | null>(null)
|
const mainImageFile = ref<File | null>(null)
|
||||||
const mainImagePreview = ref('')
|
const mainImagePreview = ref('')
|
||||||
const mainImageObjectUrl = ref('')
|
const mainImageObjectUrl = ref('')
|
||||||
@@ -739,6 +788,22 @@ const checkedCategoryIds = computed(() => {
|
|||||||
return ids
|
return ids
|
||||||
})
|
})
|
||||||
const selectedCategoryLabel = computed(() => categoryLookup.value.get(form.categoryId)?.name || '')
|
const selectedCategoryLabel = computed(() => categoryLookup.value.get(form.categoryId)?.name || '')
|
||||||
|
const selectedCategoryTrail = computed(() => {
|
||||||
|
if (!form.categoryId) return [] as string[]
|
||||||
|
|
||||||
|
const trail: string[] = []
|
||||||
|
let currentId = form.categoryId
|
||||||
|
|
||||||
|
while (currentId) {
|
||||||
|
const current = categoryLookup.value.get(currentId)
|
||||||
|
if (!current) break
|
||||||
|
trail.unshift(current.name)
|
||||||
|
currentId = current.parentId
|
||||||
|
}
|
||||||
|
|
||||||
|
return trail
|
||||||
|
})
|
||||||
|
const selectedProductTypeLabel = computed(() => productTypeOptions.find((item) => item.value === form.type)?.label || form.type)
|
||||||
const visibleCategoryRows = computed<CategoryTreeRow[]>(() => {
|
const visibleCategoryRows = computed<CategoryTreeRow[]>(() => {
|
||||||
const query = categorySearch.value.trim().toLowerCase()
|
const query = categorySearch.value.trim().toLowerCase()
|
||||||
|
|
||||||
@@ -772,6 +837,13 @@ const visibleCategoryRows = computed<CategoryTreeRow[]>(() => {
|
|||||||
})
|
})
|
||||||
const filteredBrands = computed(() => filterBrandsByProductType(brands.value, form.type))
|
const filteredBrands = computed(() => filterBrandsByProductType(brands.value, form.type))
|
||||||
const selectedBrand = computed(() => brands.value.find((brand) => brand.id === form.brandId))
|
const selectedBrand = computed(() => brands.value.find((brand) => brand.id === form.brandId))
|
||||||
|
const pageBadgeLabel = computed(() => (props.mode === 'edit' ? 'ویرایش محصول' : 'ایجاد محصول'))
|
||||||
|
const pageTitle = computed(() => (props.mode === 'edit' ? 'ویرایش محصول' : 'افزودن محصول جدید'))
|
||||||
|
const pageDescription = computed(() =>
|
||||||
|
props.mode === 'edit'
|
||||||
|
? 'مشخصات محصول، رسانهها و ترتیب نمایش ویژگیها را در همین فرم ویرایش کنید.'
|
||||||
|
: 'فرم در چند بخش مجزا چیده شده تا اطلاعات پایه، محتوا، سئو، رسانه و ویژگیها واضحتر ثبت شوند و صفحه شلوغ نشود.',
|
||||||
|
)
|
||||||
const shareImagePreview = computed(() => form.meta.shareImageUrl || '')
|
const shareImagePreview = computed(() => form.meta.shareImageUrl || '')
|
||||||
const modelDisplayName = computed(() => {
|
const modelDisplayName = computed(() => {
|
||||||
if (modelFile.value) return modelFile.value.name
|
if (modelFile.value) return modelFile.value.name
|
||||||
@@ -795,13 +867,13 @@ const mediaPickerTitle = computed(() => {
|
|||||||
const mediaPickerDescription = computed(() => {
|
const mediaPickerDescription = computed(() => {
|
||||||
switch (mediaPickerKind.value) {
|
switch (mediaPickerKind.value) {
|
||||||
case 'gallery':
|
case 'gallery':
|
||||||
return 'میتوانید چند تصویر را از Media Library انتخاب کنید یا در همان مودال فایل جدید آپلود کنید.'
|
return 'تصاویر گالری را از Media Library انتخاب کنید.'
|
||||||
case 'model':
|
case 'model':
|
||||||
return 'یک فایل مدل سهبعدی را انتخاب کنید.'
|
return 'یک فایل مدل سهبعدی را انتخاب کنید.'
|
||||||
case 'share':
|
case 'share':
|
||||||
return 'برای share image بهتر است از Media Library استفاده شود.'
|
return 'برای share image بهتر است از Media Library استفاده شود.'
|
||||||
default:
|
default:
|
||||||
return 'یک تصویر را از Media Library انتخاب کنید یا مستقیماً از همان مودال آپلود کنید.'
|
return 'رسانه موردنظر را از Media Library انتخاب کنید.'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const mediaPickerAllowedSections = computed<MediaSection[]>(() => {
|
const mediaPickerAllowedSections = computed<MediaSection[]>(() => {
|
||||||
@@ -947,44 +1019,6 @@ const mergeGalleryAssets = (items: GalleryAsset[]) => {
|
|||||||
galleryAssets.value = Array.from(map.values())
|
galleryAssets.value = Array.from(map.values())
|
||||||
}
|
}
|
||||||
|
|
||||||
const onMainImageSelected = (event: Event) => {
|
|
||||||
const input = event.target as HTMLInputElement
|
|
||||||
const file = input.files?.[0]
|
|
||||||
if (!file) return
|
|
||||||
|
|
||||||
form.existingMainImageUrl = ''
|
|
||||||
mainImageFile.value = file
|
|
||||||
resetMainImagePreview()
|
|
||||||
mainImageObjectUrl.value = URL.createObjectURL(file)
|
|
||||||
mainImagePreview.value = mainImageObjectUrl.value
|
|
||||||
input.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const onGalleryFilesSelected = (event: Event) => {
|
|
||||||
const input = event.target as HTMLInputElement
|
|
||||||
const files = Array.from(input.files || [])
|
|
||||||
if (!files.length) return
|
|
||||||
|
|
||||||
const nextAssets = files.map<GalleryAsset>((file) => ({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
name: file.name,
|
|
||||||
previewUrl: URL.createObjectURL(file),
|
|
||||||
file,
|
|
||||||
}))
|
|
||||||
mergeGalleryAssets(nextAssets)
|
|
||||||
input.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const onModelSelected = (event: Event) => {
|
|
||||||
const input = event.target as HTMLInputElement
|
|
||||||
const file = input.files?.[0]
|
|
||||||
if (!file) return
|
|
||||||
|
|
||||||
modelFile.value = file
|
|
||||||
form.existingThreeDModelUrl = ''
|
|
||||||
input.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const openMediaPicker = (kind: MediaPickerKind) => {
|
const openMediaPicker = (kind: MediaPickerKind) => {
|
||||||
mediaPickerKind.value = kind
|
mediaPickerKind.value = kind
|
||||||
isMediaPickerOpen.value = true
|
isMediaPickerOpen.value = true
|
||||||
@@ -1047,6 +1081,83 @@ const loadBootData = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mapAssignmentsToAttributeRows = (attributes: ProductListItem['attributes']): ProductAttributeFormRow[] => {
|
||||||
|
if (!Array.isArray(attributes)) return [createEmptyAttributeRow(0)]
|
||||||
|
|
||||||
|
const sorted = [...attributes].sort((first, second) => {
|
||||||
|
const firstOrder = typeof first.displayOrder === 'number' ? first.displayOrder : attributes.indexOf(first)
|
||||||
|
const secondOrder = typeof second.displayOrder === 'number' ? second.displayOrder : attributes.indexOf(second)
|
||||||
|
return firstOrder - secondOrder
|
||||||
|
})
|
||||||
|
|
||||||
|
const rows = sorted.map((item, index) => {
|
||||||
|
const reusableAttribute = item.attributeId ? reusableAttributes.value.find((entry) => entry.id === item.attributeId) : undefined
|
||||||
|
const dataType = item.dataType || reusableAttribute?.dataType || 'text'
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
attributeId: item.attributeId || '',
|
||||||
|
displayOrder: typeof item.displayOrder === 'number' ? item.displayOrder : index,
|
||||||
|
valueText: item.valueText ?? (dataType === 'select' ? String(item.valueText ?? '') : ''),
|
||||||
|
valueNumber: item.valueNumber === null || item.valueNumber === undefined ? '' : String(item.valueNumber),
|
||||||
|
valueBoolean: Boolean(item.valueBoolean),
|
||||||
|
valueJson: dataType === 'json' && item.valueJson !== undefined ? JSON.stringify(item.valueJson, null, 2) : '',
|
||||||
|
valueMultiText: dataType === 'multiselect' && Array.isArray(item.valueJson) ? item.valueJson.map((value) => String(value)) : [],
|
||||||
|
overrideUnit: item.overrideUnit || '',
|
||||||
|
} satisfies ProductAttributeFormRow
|
||||||
|
})
|
||||||
|
|
||||||
|
return normalizeAttributeDisplayOrders(rows.length ? rows : [createEmptyAttributeRow(0)])
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadProduct = async () => {
|
||||||
|
if (props.mode !== 'edit' || !props.productId) return
|
||||||
|
|
||||||
|
const product = await productService.getAdminProduct(props.productId)
|
||||||
|
const mapped = mapProductToForm(product)
|
||||||
|
|
||||||
|
form.sku = mapped.sku
|
||||||
|
form.title = mapped.title
|
||||||
|
form.slug = mapped.slug
|
||||||
|
form.technicalCode = mapped.technicalCode
|
||||||
|
form.brandId = mapped.brandId || ''
|
||||||
|
form.brand = mapped.brand || ''
|
||||||
|
form.basePriceUSD = mapped.basePriceUSD
|
||||||
|
form.salePriceUSD = mapped.salePriceUSD === '' || mapped.salePriceUSD === undefined ? null : mapped.salePriceUSD
|
||||||
|
form.stock = mapped.stock
|
||||||
|
form.type = mapped.type
|
||||||
|
form.status = mapped.status
|
||||||
|
form.featured = mapped.featured
|
||||||
|
form.categoryId = mapped.categoryId || ''
|
||||||
|
form.tags = [...mapped.tags]
|
||||||
|
form.meta = {
|
||||||
|
...createEmptyMeta(),
|
||||||
|
...mapped.meta,
|
||||||
|
}
|
||||||
|
form.existingMainImageUrl = mapped.existingMainImageUrl || ''
|
||||||
|
form.existingThreeDModelUrl = mapped.existingThreeDModelUrl || ''
|
||||||
|
|
||||||
|
mainImageFile.value = null
|
||||||
|
mainImagePreview.value = mapped.existingMainImageUrl || ''
|
||||||
|
modelFile.value = null
|
||||||
|
categorySearch.value = ''
|
||||||
|
tagInput.value = ''
|
||||||
|
slugDraft.value = mapped.slug
|
||||||
|
slugTouched.value = true
|
||||||
|
slugGeneratedOnce.value = Boolean(mapped.slug)
|
||||||
|
slugCheckState.value = 'idle'
|
||||||
|
|
||||||
|
clearGalleryAssets()
|
||||||
|
galleryAssets.value = (mapped.existingGalleryUrls || []).map((url, index) => ({
|
||||||
|
id: `existing-${index}`,
|
||||||
|
name: url.split('/').pop() || `gallery-${index + 1}`,
|
||||||
|
previewUrl: url,
|
||||||
|
existingUrl: url,
|
||||||
|
}))
|
||||||
|
|
||||||
|
attributeRows.value = mapAssignmentsToAttributeRows(product.attributes)
|
||||||
|
}
|
||||||
|
|
||||||
const checkSlugAvailability = async (slug: string) => {
|
const checkSlugAvailability = async (slug: string) => {
|
||||||
const normalized = normalizeProductSlug(slug)
|
const normalized = normalizeProductSlug(slug)
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
@@ -1056,7 +1167,10 @@ const checkSlugAvailability = async (slug: string) => {
|
|||||||
|
|
||||||
slugCheckState.value = 'checking'
|
slugCheckState.value = 'checking'
|
||||||
try {
|
try {
|
||||||
const isAvailable = await productService.checkAdminProductSlug(normalized)
|
const isAvailable = await productService.checkAdminProductSlug(
|
||||||
|
normalized,
|
||||||
|
props.mode === 'edit' && props.productId ? props.productId : undefined,
|
||||||
|
)
|
||||||
slugCheckState.value = isAvailable ? 'available' : 'taken'
|
slugCheckState.value = isAvailable ? 'available' : 'taken'
|
||||||
} catch {
|
} catch {
|
||||||
slugCheckState.value = 'idle'
|
slugCheckState.value = 'idle'
|
||||||
@@ -1113,7 +1227,10 @@ const submitCreateAttribute = async () => {
|
|||||||
if (emptyRow) {
|
if (emptyRow) {
|
||||||
emptyRow.attributeId = created.id
|
emptyRow.attributeId = created.id
|
||||||
} else {
|
} else {
|
||||||
attributeRows.value.push({ ...createEmptyAttributeRow(), attributeId: created.id })
|
attributeRows.value = normalizeAttributeDisplayOrders([
|
||||||
|
...attributeRows.value,
|
||||||
|
{ ...createEmptyAttributeRow(attributeRows.value.length), attributeId: created.id },
|
||||||
|
])
|
||||||
}
|
}
|
||||||
closeCreateAttributeModal()
|
closeCreateAttributeModal()
|
||||||
await Swal.fire({ icon: 'success', title: 'موفق', text: 'ویژگی reusable ساخته شد', timer: 1400, showConfirmButton: false })
|
await Swal.fire({ icon: 'success', title: 'موفق', text: 'ویژگی reusable ساخته شد', timer: 1400, showConfirmButton: false })
|
||||||
@@ -1127,13 +1244,19 @@ const submitCreateAttribute = async () => {
|
|||||||
const getReusableAttribute = (attributeId: string) => reusableAttributes.value.find((item) => item.id === attributeId)
|
const getReusableAttribute = (attributeId: string) => reusableAttributes.value.find((item) => item.id === attributeId)
|
||||||
|
|
||||||
const buildAttributeAssignments = (): ProductAttributeAssignment[] => {
|
const buildAttributeAssignments = (): ProductAttributeAssignment[] => {
|
||||||
return attributeRows.value
|
return normalizeAttributeDisplayOrders(attributeRows.value)
|
||||||
.filter((row) => row.attributeId)
|
.filter((row) => row.attributeId)
|
||||||
.map((row) => {
|
.map((row) => {
|
||||||
const attribute = getReusableAttribute(row.attributeId)
|
const attribute = getReusableAttribute(row.attributeId)
|
||||||
const base: ProductAttributeAssignment = {
|
const base: ProductAttributeAssignment = {
|
||||||
attributeId: row.attributeId,
|
attributeId: row.attributeId,
|
||||||
|
displayOrder: row.displayOrder,
|
||||||
overrideUnit: row.overrideUnit.trim() || undefined,
|
overrideUnit: row.overrideUnit.trim() || undefined,
|
||||||
|
name: attribute?.name,
|
||||||
|
slug: attribute?.slug,
|
||||||
|
dataType: attribute?.dataType,
|
||||||
|
isVisible: attribute?.isVisible,
|
||||||
|
isFilterable: attribute?.isFilterable,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!attribute) return base
|
if (!attribute) return base
|
||||||
@@ -1194,13 +1317,13 @@ const validateAttributeRows = () => {
|
|||||||
|
|
||||||
const validateForm = () => {
|
const validateForm = () => {
|
||||||
const errors: string[] = []
|
const errors: string[] = []
|
||||||
if (!form.sku.trim()) errors.push('SKU الزامی است')
|
if (props.mode === 'edit' && !form.sku.trim()) errors.push('SKU الزامی است')
|
||||||
if (!form.title.trim()) errors.push('عنوان محصول الزامی است')
|
if (!form.title.trim()) errors.push('عنوان محصول الزامی است')
|
||||||
if (!form.slug.trim()) errors.push('Slug الزامی است')
|
if (!form.slug.trim()) errors.push('Slug الزامی است')
|
||||||
if (form.slug.trim().length > MAX_SLUG_LENGTH) errors.push('Slug نمیتواند بیشتر از 16 کاراکتر باشد')
|
if (form.slug.trim().length > MAX_SLUG_LENGTH) errors.push('Slug نمیتواند بیشتر از 16 کاراکتر باشد')
|
||||||
if (slugCheckState.value === 'taken') errors.push('این اسلاگ قبلاً ثبت شده است')
|
if (slugCheckState.value === 'taken') errors.push('این اسلاگ قبلاً ثبت شده است')
|
||||||
if (!form.technicalCode.trim()) errors.push('Technical code الزامی است')
|
if (props.mode === 'edit' && !form.technicalCode.trim()) errors.push('Technical code الزامی است')
|
||||||
if (!form.brandId && !form.brand.trim()) errors.push('برند الزامی است')
|
if (props.mode === 'edit' && !form.brandId && !form.brand.trim()) errors.push('برند الزامی است')
|
||||||
if (!form.categoryId) errors.push('دستهبندی محصول الزامی است')
|
if (!form.categoryId) errors.push('دستهبندی محصول الزامی است')
|
||||||
if (Number(form.basePriceUSD) < 0) errors.push('قیمت اصلی نمیتواند منفی باشد')
|
if (Number(form.basePriceUSD) < 0) errors.push('قیمت اصلی نمیتواند منفی باشد')
|
||||||
if (form.salePriceUSD !== null && Number(form.salePriceUSD) < 0) errors.push('قیمت فروش نمیتواند منفی باشد')
|
if (form.salePriceUSD !== null && Number(form.salePriceUSD) < 0) errors.push('قیمت فروش نمیتواند منفی باشد')
|
||||||
@@ -1236,7 +1359,7 @@ const resetForm = () => {
|
|||||||
slugGeneratedOnce.value = false
|
slugGeneratedOnce.value = false
|
||||||
slugCheckState.value = 'idle'
|
slugCheckState.value = 'idle'
|
||||||
tagInput.value = ''
|
tagInput.value = ''
|
||||||
attributeRows.value = [createEmptyAttributeRow()]
|
attributeRows.value = [createEmptyAttributeRow(0)]
|
||||||
validationErrors.value = []
|
validationErrors.value = []
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
slugTouched.value = false
|
slugTouched.value = false
|
||||||
@@ -1251,8 +1374,6 @@ const submitForm = async () => {
|
|||||||
sku: form.sku.trim(),
|
sku: form.sku.trim(),
|
||||||
title: form.title.trim(),
|
title: form.title.trim(),
|
||||||
slug: normalizeProductSlug(form.slug.trim()),
|
slug: normalizeProductSlug(form.slug.trim()),
|
||||||
summary: form.meta.shortDescription.trim(),
|
|
||||||
description: form.meta.description.trim(),
|
|
||||||
meta: {
|
meta: {
|
||||||
...form.meta,
|
...form.meta,
|
||||||
shortDescription: form.meta.shortDescription.trim(),
|
shortDescription: form.meta.shortDescription.trim(),
|
||||||
@@ -1272,7 +1393,8 @@ const submitForm = async () => {
|
|||||||
featured: form.featured,
|
featured: form.featured,
|
||||||
type: form.type,
|
type: form.type,
|
||||||
status: form.status,
|
status: form.status,
|
||||||
categoryId: form.categoryId || undefined,
|
primaryCategoryId: form.categoryId || undefined,
|
||||||
|
categoryIds: form.categoryId ? [form.categoryId] : [],
|
||||||
tags: [...form.tags],
|
tags: [...form.tags],
|
||||||
attributes: buildAttributeAssignments(),
|
attributes: buildAttributeAssignments(),
|
||||||
existingMainImageUrl: form.existingMainImageUrl || undefined,
|
existingMainImageUrl: form.existingMainImageUrl || undefined,
|
||||||
@@ -1285,7 +1407,10 @@ const submitForm = async () => {
|
|||||||
|
|
||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
const response = await productService.createAdminProduct(payload)
|
const response =
|
||||||
|
props.mode === 'edit' && props.productId
|
||||||
|
? await productService.updateAdminProduct(props.productId, payload)
|
||||||
|
: await productService.createAdminProduct(payload)
|
||||||
await Swal.fire({ icon: 'success', title: 'محصول ایجاد شد', text: 'محصول با موفقیت ذخیره شد', timer: 1500, showConfirmButton: false })
|
await Swal.fire({ icon: 'success', title: 'محصول ایجاد شد', text: 'محصول با موفقیت ذخیره شد', timer: 1500, showConfirmButton: false })
|
||||||
router.push(`/admin/products/${response.id}`)
|
router.push(`/admin/products/${response.id}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1349,6 +1474,13 @@ watch(
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadBootData()
|
await loadBootData()
|
||||||
|
if (!errorMessage.value) {
|
||||||
|
try {
|
||||||
|
await loadProduct()
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = extractApiErrorMessage(error, 'بارگذاری اطلاعات محصول انجام نشد')
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
<span class="text-xs text-white-dark">ویژگیها از API خوانده میشوند و هر ردیف فقط یک ویژگی را مدیریت میکند.</span>
|
<span class="text-xs text-white-dark">ویژگیها از API خوانده میشوند و هر ردیف فقط یک ویژگی را مدیریت میکند.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-primary/15 bg-primary/5 px-4 py-3 text-xs leading-6 text-primary dark:border-primary/20">
|
||||||
|
دو ویژگی اول، در لیست محصولات سایت نمایش داده میشوند. برای تعیین ترتیب نمایش، ردیفها را با دکمههای بالا و پایین جابهجا کنید.
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="isLoading" class="grid min-h-[180px] place-content-center rounded-2xl border border-dashed border-white-light dark:border-[#1b2e4b]">
|
<div v-if="isLoading" class="grid min-h-[180px] place-content-center rounded-2xl border border-dashed border-white-light dark:border-[#1b2e4b]">
|
||||||
<span class="inline-flex h-8 w-8 animate-spin rounded-full border-4 border-primary border-l-transparent"></span>
|
<span class="inline-flex h-8 w-8 animate-spin rounded-full border-4 border-primary border-l-transparent"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -18,14 +22,42 @@
|
|||||||
<div
|
<div
|
||||||
v-for="(row, index) in rows"
|
v-for="(row, index) in rows"
|
||||||
:key="row.id"
|
:key="row.id"
|
||||||
class="rounded-2xl border border-white-light/80 bg-white-light/20 p-4 dark:border-[#1b2e4b] dark:bg-[#060818]"
|
class="overflow-hidden rounded-2xl border border-white-light/80 bg-white-light/20 dark:border-[#1b2e4b] dark:bg-[#060818]"
|
||||||
>
|
>
|
||||||
<div class="mb-4 flex items-center justify-between gap-3">
|
<button
|
||||||
<div>
|
type="button"
|
||||||
<div class="text-sm font-semibold text-black dark:text-white">ویژگی {{ index + 1 }}</div>
|
class="flex w-full items-start justify-between gap-3 px-4 py-4 text-right transition hover:bg-white/40 dark:hover:bg-white/5"
|
||||||
<div class="text-xs text-white-dark">{{ getAttribute(row.attributeId)?.slug || 'ویژگی انتخاب نشده' }}</div>
|
@click="toggleRow(row.id)"
|
||||||
|
>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<div class="min-h-[1.25rem] text-sm font-semibold text-black dark:text-white">{{ getAttribute(row.attributeId)?.name || '' }}</div>
|
||||||
|
<span class="badge bg-dark/10 text-dark dark:bg-white/10 dark:text-white">ترتیب {{ row.displayOrder }}</span>
|
||||||
|
<span v-if="index < 2" class="badge bg-success/15 text-success">نمایش در لیست</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs text-white-dark">
|
||||||
|
<span v-if="getAttribute(row.attributeId)?.name" class="truncate">{{ getAttribute(row.attributeId)?.name }}</span>
|
||||||
|
<span v-if="summaryUnit(row)">واحد: {{ summaryUnit(row) }}</span>
|
||||||
|
<span v-if="summaryValue(row)">مقدار: {{ summaryValue(row) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<IconCaretDown class="mt-1 h-4 w-4 shrink-0 transition" :class="isRowOpen(row.id) ? 'rotate-180 text-primary' : 'text-white-dark'" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="isRowOpen(row.id)" class="border-t border-white-light/80 px-4 py-4 dark:border-[#1b2e4b]">
|
||||||
|
<div class="mb-4 flex items-center justify-between gap-3">
|
||||||
|
<div class="text-xs text-white-dark">{{ getAttribute(row.attributeId)?.slug || '' }}</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm !px-2.5 text-[11px]" :disabled="index === 0" @click.stop="moveRow(index, -1)">↑</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm !px-2.5 text-[11px]" :disabled="index === rows.length - 1" @click.stop="moveRow(index, 1)">↓</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="grid h-8 w-8 place-content-center rounded-lg border border-danger/20 text-danger transition hover:bg-danger/10"
|
||||||
|
@click.stop="removeRow(index)"
|
||||||
|
>
|
||||||
|
<IconTrash class="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-outline-danger btn-sm" @click="removeRow(index)">حذف</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_180px]">
|
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_180px]">
|
||||||
@@ -110,9 +142,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import IconCaretDown from '@/components/icon/icon-caret-down.vue'
|
||||||
|
import IconTrash from '@/components/icon/icon-trash.vue'
|
||||||
import type { ReusableProductAttribute } from '@/types/product'
|
import type { ReusableProductAttribute } from '@/types/product'
|
||||||
import type { ProductAttributeFormRow } from '@/types/admin-product-create'
|
import type { ProductAttributeFormRow } from '@/types/admin-product-create'
|
||||||
|
|
||||||
@@ -127,6 +163,8 @@ defineEmits<{
|
|||||||
(event: 'request-create'): void
|
(event: 'request-create'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const openRowId = ref('')
|
||||||
|
|
||||||
const dataTypeLabelMap = {
|
const dataTypeLabelMap = {
|
||||||
text: 'متنی',
|
text: 'متنی',
|
||||||
number: 'عددی',
|
number: 'عددی',
|
||||||
@@ -136,9 +174,26 @@ const dataTypeLabelMap = {
|
|||||||
json: 'JSON',
|
json: 'JSON',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
const syncDisplayOrders = () => {
|
||||||
|
rows.value.forEach((row, index) => {
|
||||||
|
row.displayOrder = index
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAttribute = (attributeId: string) => {
|
||||||
|
return props.reusableAttributes.find((attribute) => attribute.id === attributeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRowOpen = (rowId: string) => openRowId.value === rowId
|
||||||
|
|
||||||
|
const toggleRow = (rowId: string) => {
|
||||||
|
openRowId.value = openRowId.value === rowId ? '' : rowId
|
||||||
|
}
|
||||||
|
|
||||||
const createEmptyRow = (): ProductAttributeFormRow => ({
|
const createEmptyRow = (): ProductAttributeFormRow => ({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
attributeId: '',
|
attributeId: '',
|
||||||
|
displayOrder: rows.value.length,
|
||||||
valueText: '',
|
valueText: '',
|
||||||
valueNumber: '',
|
valueNumber: '',
|
||||||
valueBoolean: false,
|
valueBoolean: false,
|
||||||
@@ -147,16 +202,56 @@ const createEmptyRow = (): ProductAttributeFormRow => ({
|
|||||||
overrideUnit: '',
|
overrideUnit: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const summaryUnit = (row: ProductAttributeFormRow) => row.overrideUnit || getAttribute(row.attributeId)?.unit || ''
|
||||||
|
|
||||||
|
const summaryValue = (row: ProductAttributeFormRow) => {
|
||||||
|
const attribute = getAttribute(row.attributeId)
|
||||||
|
if (!attribute) return ''
|
||||||
|
|
||||||
|
switch (attribute.dataType) {
|
||||||
|
case 'number':
|
||||||
|
return row.valueNumber
|
||||||
|
case 'boolean':
|
||||||
|
return row.valueBoolean ? 'بله' : 'خیر'
|
||||||
|
case 'multiselect':
|
||||||
|
return row.valueMultiText.join('، ')
|
||||||
|
case 'json':
|
||||||
|
return row.valueJson.trim() ? 'JSON' : ''
|
||||||
|
default:
|
||||||
|
return row.valueText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const addRow = () => {
|
const addRow = () => {
|
||||||
rows.value.push(createEmptyRow())
|
const nextRow = createEmptyRow()
|
||||||
|
rows.value.push(nextRow)
|
||||||
|
syncDisplayOrders()
|
||||||
|
openRowId.value = nextRow.id
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeRow = (index: number) => {
|
const removeRow = (index: number) => {
|
||||||
|
const removed = rows.value[index]
|
||||||
rows.value.splice(index, 1)
|
rows.value.splice(index, 1)
|
||||||
|
syncDisplayOrders()
|
||||||
|
|
||||||
|
if (!rows.value.length) {
|
||||||
|
openRowId.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removed && openRowId.value === removed.id) {
|
||||||
|
openRowId.value = rows.value[Math.min(index, rows.value.length - 1)].id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAttribute = (attributeId: string) => {
|
const moveRow = (index: number, direction: -1 | 1) => {
|
||||||
return props.reusableAttributes.find((attribute) => attribute.id === attributeId)
|
const nextIndex = index + direction
|
||||||
|
if (nextIndex < 0 || nextIndex >= rows.value.length) return
|
||||||
|
|
||||||
|
const [target] = rows.value.splice(index, 1)
|
||||||
|
rows.value.splice(nextIndex, 0, target)
|
||||||
|
syncDisplayOrders()
|
||||||
|
openRowId.value = target.id
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetRowValues = (row: ProductAttributeFormRow) => {
|
const resetRowValues = (row: ProductAttributeFormRow) => {
|
||||||
@@ -169,6 +264,7 @@ const resetRowValues = (row: ProductAttributeFormRow) => {
|
|||||||
|
|
||||||
const onAttributeChange = (row: ProductAttributeFormRow) => {
|
const onAttributeChange = (row: ProductAttributeFormRow) => {
|
||||||
resetRowValues(row)
|
resetRowValues(row)
|
||||||
|
openRowId.value = row.id
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleMultiValue = (row: ProductAttributeFormRow, value: string) => {
|
const toggleMultiValue = (row: ProductAttributeFormRow, value: string) => {
|
||||||
@@ -179,4 +275,23 @@ const toggleMultiValue = (row: ProductAttributeFormRow, value: string) => {
|
|||||||
|
|
||||||
row.valueMultiText = [...row.valueMultiText, value]
|
row.valueMultiText = [...row.valueMultiText, value]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (rows.value.length && !openRowId.value) {
|
||||||
|
openRowId.value = rows.value[0].id
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => rows.value.map((row) => row.id),
|
||||||
|
(ids) => {
|
||||||
|
if (!ids.length) {
|
||||||
|
openRowId.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ids.includes(openRowId.value)) {
|
||||||
|
openRowId.value = ids[0]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -537,7 +537,7 @@ const validateForm = () => {
|
|||||||
if (!form.sku.trim()) return 'SKU الزامی است'
|
if (!form.sku.trim()) return 'SKU الزامی است'
|
||||||
if (!form.slug.trim()) return 'اسلاگ الزامی است'
|
if (!form.slug.trim()) return 'اسلاگ الزامی است'
|
||||||
if (!form.technicalCode.trim()) return 'کد فنی الزامی است'
|
if (!form.technicalCode.trim()) return 'کد فنی الزامی است'
|
||||||
if (!form.brandId && !form.brand.trim()) return 'برند الزامی است'
|
if (!form.brandId && !(form.brand || '').trim()) return 'برند الزامی است'
|
||||||
if (form.basePriceUSD < 0) return 'قیمت پایه نمی تواند منفی باشد'
|
if (form.basePriceUSD < 0) return 'قیمت پایه نمی تواند منفی باشد'
|
||||||
if (form.salePriceUSD !== null && form.salePriceUSD !== undefined && form.salePriceUSD !== '' && Number(form.salePriceUSD) < 0) {
|
if (form.salePriceUSD !== null && form.salePriceUSD !== undefined && form.salePriceUSD !== '' && Number(form.salePriceUSD) < 0) {
|
||||||
return 'قیمت فروش نمی تواند منفی باشد'
|
return 'قیمت فروش نمی تواند منفی باشد'
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ const menuSections: MenuSection[] = [
|
|||||||
children: [
|
children: [
|
||||||
{ label: 'تنظیمات عمومی', to: '/admin/settings' },
|
{ label: 'تنظیمات عمومی', to: '/admin/settings' },
|
||||||
{ label: 'درگاه های پرداخت', to: '/admin/settings/payment-gateways' },
|
{ label: 'درگاه های پرداخت', to: '/admin/settings/payment-gateways' },
|
||||||
|
{ label: 'فیش های بانکی ارسالی', to: '/admin/payments/bank-slips' },
|
||||||
{ label: 'روش های ارسال', to: '/admin/settings/shipping-methods' },
|
{ label: 'روش های ارسال', to: '/admin/settings/shipping-methods' },
|
||||||
{ label: 'تنظیمات سئو', to: '/admin/settings/seo' },
|
{ label: 'تنظیمات سئو', to: '/admin/settings/seo' },
|
||||||
],
|
],
|
||||||
|
|||||||
14
src/config/api.ts
Normal file
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('support/product-questions', 'AdminSupportProductQuestions', 'سوالات محصولات', 'مدیریت پرسش و پاسخ های ثبت شده برای کالاها'),
|
||||||
createAdminPlaceholderRoute('users/roles', 'AdminUsersRoles', 'نقش ها و دسترسی ها', 'مدیریت نقش های سازمانی و سطح دسترسی کاربران پنل'),
|
createAdminPlaceholderRoute('users/roles', 'AdminUsersRoles', 'نقش ها و دسترسی ها', 'مدیریت نقش های سازمانی و سطح دسترسی کاربران پنل'),
|
||||||
createAdminPlaceholderRoute('users/activity-logs', 'AdminUsersActivityLogs', 'لاگ فعالیت ها', 'مشاهده فعالیت های مدیران و اپراتورهای پنل'),
|
createAdminPlaceholderRoute('users/activity-logs', 'AdminUsersActivityLogs', 'لاگ فعالیت ها', 'مشاهده فعالیت های مدیران و اپراتورهای پنل'),
|
||||||
createAdminPlaceholderRoute('settings/payment-gateways', 'AdminSettingsPaymentGateways', 'درگاه های پرداخت', 'تنظیم و مدیریت درگاه های پرداخت فعال'),
|
{
|
||||||
|
path: 'settings/payment-gateways',
|
||||||
|
name: 'AdminSettingsPaymentGateways',
|
||||||
|
component: () => import('@/views/admin/AdminSettingsPaymentGateways.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'درگاه های پرداخت',
|
||||||
|
description: 'تنظیم و مدیریت درگاه های پرداخت فعال',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'payments/bank-slips',
|
||||||
|
name: 'AdminPaymentBankSlips',
|
||||||
|
component: () => import('@/views/admin/AdminPaymentBankSlips.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'فیش های بانکی ارسالی',
|
||||||
|
description: 'بررسی و تایید یا رد فیش های بانکی ثبت شده',
|
||||||
|
},
|
||||||
|
},
|
||||||
createAdminPlaceholderRoute('settings/shipping-methods', 'AdminSettingsShippingMethods', 'روش های ارسال', 'مدیریت روش های ارسال، پیک و پست'),
|
createAdminPlaceholderRoute('settings/shipping-methods', 'AdminSettingsShippingMethods', 'روش های ارسال', 'مدیریت روش های ارسال، پیک و پست'),
|
||||||
createAdminPlaceholderRoute('settings/seo', 'AdminSettingsSeo', 'تنظیمات سئو', 'مدیریت تنظیمات فنی و محتوایی سئو فروشگاه'),
|
createAdminPlaceholderRoute('settings/seo', 'AdminSettingsSeo', 'تنظیمات سئو', 'مدیریت تنظیمات فنی و محتوایی سئو فروشگاه'),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -82,14 +82,14 @@ export const buildProductFormData = (payload: ProductFormPayload): FormData => {
|
|||||||
formData.append('sku', payload.sku)
|
formData.append('sku', payload.sku)
|
||||||
formData.append('title', payload.title)
|
formData.append('title', payload.title)
|
||||||
formData.append('slug', payload.slug)
|
formData.append('slug', payload.slug)
|
||||||
formData.append('summary', payload.summary ?? normalizedMeta.shortDescription)
|
|
||||||
formData.append('description', payload.description ?? normalizedMeta.description)
|
|
||||||
formData.append('meta', JSON.stringify(normalizedMeta))
|
formData.append('meta', JSON.stringify(normalizedMeta))
|
||||||
formData.append('technicalCode', payload.technicalCode)
|
formData.append('technicalCode', payload.technicalCode)
|
||||||
if (payload.brandId !== undefined) {
|
if (payload.brandId !== undefined) {
|
||||||
formData.append('brandId', payload.brandId)
|
formData.append('brandId', payload.brandId)
|
||||||
}
|
}
|
||||||
|
if (payload.brand) {
|
||||||
formData.append('brand', payload.brand)
|
formData.append('brand', payload.brand)
|
||||||
|
}
|
||||||
formData.append('basePriceUSD', String(payload.basePriceUSD))
|
formData.append('basePriceUSD', String(payload.basePriceUSD))
|
||||||
formData.append('stock', String(payload.stock))
|
formData.append('stock', String(payload.stock))
|
||||||
formData.append('featured', String(payload.featured))
|
formData.append('featured', String(payload.featured))
|
||||||
@@ -97,14 +97,15 @@ export const buildProductFormData = (payload: ProductFormPayload): FormData => {
|
|||||||
formData.append('status', payload.status)
|
formData.append('status', payload.status)
|
||||||
formData.append('attributes', JSON.stringify(payload.attributes || []))
|
formData.append('attributes', JSON.stringify(payload.attributes || []))
|
||||||
formData.append('tags', JSON.stringify(payload.tags || []))
|
formData.append('tags', JSON.stringify(payload.tags || []))
|
||||||
|
formData.append('categoryIds', JSON.stringify(payload.categoryIds || []))
|
||||||
formData.append('existingGalleryUrls', JSON.stringify(payload.existingGalleryUrls || []))
|
formData.append('existingGalleryUrls', JSON.stringify(payload.existingGalleryUrls || []))
|
||||||
|
|
||||||
if (payload.salePriceUSD !== undefined && payload.salePriceUSD !== null) {
|
if (payload.salePriceUSD !== undefined && payload.salePriceUSD !== null) {
|
||||||
formData.append('salePriceUSD', String(payload.salePriceUSD))
|
formData.append('salePriceUSD', String(payload.salePriceUSD))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.categoryId) {
|
if (payload.primaryCategoryId) {
|
||||||
formData.append('categoryId', payload.categoryId)
|
formData.append('primaryCategoryId', payload.primaryCategoryId)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.existingMainImageUrl !== undefined) {
|
if (payload.existingMainImageUrl !== undefined) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||||
|
import { API_BASE_URL } from '@/config/api'
|
||||||
import type {
|
import type {
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
AuthResponse,
|
AuthResponse,
|
||||||
@@ -17,7 +18,7 @@ class ApiService {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.api = axios.create({
|
this.api = axios.create({
|
||||||
baseURL: '/api', // استفاده از proxy
|
baseURL: API_BASE_URL,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Accept': 'application/json'
|
'Accept': 'application/json'
|
||||||
|
|||||||
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 {
|
export interface ProductAttributeFormRow {
|
||||||
id: string
|
id: string
|
||||||
attributeId: string
|
attributeId: string
|
||||||
|
displayOrder: number
|
||||||
valueText: string
|
valueText: string
|
||||||
valueNumber: string
|
valueNumber: string
|
||||||
valueBoolean: boolean
|
valueBoolean: boolean
|
||||||
|
|||||||
100
src/types/payment.ts
Normal file
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 {
|
export interface ProductAttributeAssignment {
|
||||||
attributeId?: string
|
attributeId?: string
|
||||||
|
displayOrder?: number
|
||||||
name?: string
|
name?: string
|
||||||
slug?: string
|
slug?: string
|
||||||
dataType?: ProductAttributeDataType
|
dataType?: ProductAttributeDataType
|
||||||
@@ -202,12 +203,12 @@ export interface ProductFormPayload {
|
|||||||
sku: string
|
sku: string
|
||||||
title: string
|
title: string
|
||||||
slug: string
|
slug: string
|
||||||
summary: string
|
summary?: string
|
||||||
description: string
|
description?: string
|
||||||
meta?: ProductMeta
|
meta?: ProductMeta
|
||||||
technicalCode: string
|
technicalCode: string
|
||||||
brandId?: string
|
brandId?: string
|
||||||
brand: string
|
brand?: string
|
||||||
basePriceUSD: number
|
basePriceUSD: number
|
||||||
salePriceUSD?: number | null | ''
|
salePriceUSD?: number | null | ''
|
||||||
stock: number
|
stock: number
|
||||||
@@ -215,6 +216,8 @@ export interface ProductFormPayload {
|
|||||||
type: ProductType
|
type: ProductType
|
||||||
status: ProductStatus
|
status: ProductStatus
|
||||||
categoryId?: string
|
categoryId?: string
|
||||||
|
primaryCategoryId?: string
|
||||||
|
categoryIds?: string[]
|
||||||
attributes: ProductAttributes | ProductAttributeAssignment[]
|
attributes: ProductAttributes | ProductAttributeAssignment[]
|
||||||
tags: string[]
|
tags: string[]
|
||||||
existingMainImageUrl?: string
|
existingMainImageUrl?: string
|
||||||
|
|||||||
14
src/types/settings.ts
Normal file
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>
|
<template>
|
||||||
<ProductForm mode="edit" :product-id="String(route.params.id)" />
|
<AdminProductCreateForm mode="edit" :product-id="String(route.params.id || '')" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import ProductForm from '@/components/admin/products/ProductForm.vue'
|
import AdminProductCreateForm from '@/components/admin/products/AdminProductCreateForm.vue'
|
||||||
import { useMeta } from '@/composables/use-meta'
|
import { useMeta } from '@/composables/use-meta'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|||||||
@@ -1,8 +1,227 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-black dark:text-white mb-8">تنظیمات سیستم</h1>
|
<h1 class="text-3xl font-bold text-black dark:text-white">تنظیمات سیستم</h1>
|
||||||
|
<p class="mt-1 text-white-dark">تنظیمات مالی فروشگاه را از این بخش مدیریت کنید تا قیمتگذاری محصولات بر اساس نرخ واحد انجام شود.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" :disabled="isLoading || isSubmitting" @click="loadPricingSettings">
|
||||||
|
بارگذاری مجدد
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1.55fr)_minmax(320px,0.85fr)]">
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<p class="text-white-dark">تنظیمات پنل مدیریت</p>
|
<div class="mb-5 flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="inline-flex rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold text-primary">مالی</div>
|
||||||
|
<h2 class="mt-3 text-xl font-semibold text-black dark:text-white">تنظیمات قیمت</h2>
|
||||||
|
<p class="mt-1 text-sm text-white-dark">نرخ تبدیل دلار به ریال و واحد نمایش پیشفرض قیمت محصولات را مشخص کنید.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorMessage" class="mb-5 rounded-md border border-danger bg-danger/10 px-4 py-3 text-sm text-danger">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isLoading" class="grid min-h-[320px] place-content-center">
|
||||||
|
<span class="inline-flex h-10 w-10 animate-spin rounded-full border-4 border-primary border-l-transparent"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form v-else class="space-y-5" @submit.prevent="submitPricingSettings">
|
||||||
|
<div class="grid gap-5 lg:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="pricing-usd-rate">نرخ دلار به ریال</label>
|
||||||
|
<input
|
||||||
|
id="pricing-usd-rate"
|
||||||
|
v-model.number="form.usdToIrrRate"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="مثلاً 925000"
|
||||||
|
/>
|
||||||
|
<p class="mt-2 text-xs text-white-dark">این عدد مبنای تبدیل قیمت دلاری محصولات در کل فروشگاه است.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="pricing-currency-display">واحد نمایش پیشفرض</label>
|
||||||
|
<select id="pricing-currency-display" v-model="form.defaultCurrencyDisplay" class="form-select">
|
||||||
|
<option v-for="option in currencyDisplayOptions" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||||
|
</select>
|
||||||
|
<p class="mt-2 text-xs text-white-dark">کاربران قیمتها را بهصورت پیشفرض با این واحد میبینند.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-white-light bg-white-light/30 px-4 py-4 dark:border-[#1b2e4b] dark:bg-[#060818]">
|
||||||
|
<div class="mb-3 text-sm font-semibold text-black dark:text-white">پیشنمایش سریع</div>
|
||||||
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
|
<div class="rounded-xl bg-white px-4 py-4 shadow-sm dark:bg-[#0e1726]">
|
||||||
|
<div class="text-xs text-white-dark">نرخ فعلی</div>
|
||||||
|
<div class="mt-2 text-lg font-bold text-black dark:text-white">{{ formatNumber(form.usdToIrrRate) }} ریال</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl bg-white px-4 py-4 shadow-sm dark:bg-[#0e1726]">
|
||||||
|
<div class="text-xs text-white-dark">نمایش پیشفرض</div>
|
||||||
|
<div class="mt-2 text-lg font-bold text-black dark:text-white">{{ selectedCurrencyLabel }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-white-light pt-5 dark:border-[#1b2e4b]">
|
||||||
|
<div class="text-xs leading-6 text-white-dark">
|
||||||
|
<div>ایجاد: {{ pricingSettings ? formatDateTime(pricingSettings.createdAt) : '-' }}</div>
|
||||||
|
<div>آخرین بروزرسانی: {{ pricingSettings ? formatDateTime(pricingSettings.updatedAt) : '-' }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" :disabled="isSubmitting" @click="resetForm">بازنشانی</button>
|
||||||
|
<button type="submit" class="btn btn-primary min-w-[140px]" :disabled="isSubmitting">
|
||||||
|
<span v-if="isSubmitting">در حال ذخیره...</span>
|
||||||
|
<span v-else>ذخیره تنظیمات</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="panel">
|
||||||
|
<h3 class="text-lg font-semibold text-black dark:text-white">نکات این بخش</h3>
|
||||||
|
<div class="mt-4 space-y-3 text-sm leading-7 text-white-dark">
|
||||||
|
<p>بعد از ذخیره موفق، بکاند cache قیمت محصولات را invalidate میکند و نیازی به refresh دستی از سمت فرانت نیست.</p>
|
||||||
|
<p>اگر هنوز تنظیمی در دیتابیس نباشد، بکاند یک رکورد پیشفرض میسازد و این صفحه همان را لود میکند.</p>
|
||||||
|
<p>برای جلوگیری از خطای اعتبارسنجی، نرخ دلار فقط باید عددی بزرگتر یا مساوی صفر باشد.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h3 class="text-lg font-semibold text-black dark:text-white">واحدهای مجاز</h3>
|
||||||
|
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<div class="rounded-xl border border-white-light px-4 py-4 dark:border-[#1b2e4b]">
|
||||||
|
<div class="text-sm font-semibold text-black dark:text-white">IRR</div>
|
||||||
|
<div class="mt-1 text-xs text-white-dark">نمایش قیمت بر اساس ریال</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-white-light px-4 py-4 dark:border-[#1b2e4b]">
|
||||||
|
<div class="text-sm font-semibold text-black dark:text-white">TOMAN</div>
|
||||||
|
<div class="mt-1 text-xs text-white-dark">نمایش قیمت بر اساس تومان</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import Swal from 'sweetalert2'
|
||||||
|
import { extractApiErrorMessage } from '@/services/admin-api'
|
||||||
|
import settingsService from '@/services/settings-service'
|
||||||
|
import type { CurrencyDisplay, PricingSettings } from '@/types/settings'
|
||||||
|
|
||||||
|
const currencyDisplayOptions: Array<{ value: CurrencyDisplay; label: string }> = [
|
||||||
|
{ value: 'IRR', label: 'IRR' },
|
||||||
|
{ value: 'TOMAN', label: 'TOMAN' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const pricingSettings = ref<PricingSettings | null>(null)
|
||||||
|
const isLoading = ref(true)
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
const form = reactive<{
|
||||||
|
usdToIrrRate: number
|
||||||
|
defaultCurrencyDisplay: CurrencyDisplay
|
||||||
|
}>({
|
||||||
|
usdToIrrRate: 0,
|
||||||
|
defaultCurrencyDisplay: 'IRR',
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedCurrencyLabel = computed(() => {
|
||||||
|
return currencyDisplayOptions.find((option) => option.value === form.defaultCurrencyDisplay)?.label || form.defaultCurrencyDisplay
|
||||||
|
})
|
||||||
|
|
||||||
|
const syncForm = (settings: PricingSettings) => {
|
||||||
|
pricingSettings.value = settings
|
||||||
|
form.usdToIrrRate = Number(settings.usdToIrrRate ?? 0)
|
||||||
|
form.defaultCurrencyDisplay = settings.defaultCurrencyDisplay
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadPricingSettings = async () => {
|
||||||
|
isLoading.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await settingsService.getPricingSettings()
|
||||||
|
syncForm(response)
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = extractApiErrorMessage(error, 'بارگذاری تنظیمات قیمت انجام نشد')
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
if (!pricingSettings.value) return
|
||||||
|
syncForm(pricingSettings.value)
|
||||||
|
errorMessage.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatNumber = (value: number) => new Intl.NumberFormat('fa-IR').format(Number(value || 0))
|
||||||
|
|
||||||
|
const formatDateTime = (value: string) => {
|
||||||
|
if (!value) return '-'
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat('fa-IR', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}).format(new Date(value))
|
||||||
|
} catch {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitPricingSettings = async () => {
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
if (!Number.isFinite(Number(form.usdToIrrRate)) || Number(form.usdToIrrRate) < 0) {
|
||||||
|
errorMessage.value = 'نرخ دلار به ریال باید عددی معتبر و بزرگتر یا مساوی صفر باشد'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await settingsService.updatePricingSettings({
|
||||||
|
usdToIrrRate: Number(form.usdToIrrRate),
|
||||||
|
defaultCurrencyDisplay: form.defaultCurrencyDisplay,
|
||||||
|
})
|
||||||
|
|
||||||
|
syncForm(response)
|
||||||
|
|
||||||
|
await Swal.fire({
|
||||||
|
icon: 'success',
|
||||||
|
title: 'تنظیمات ذخیره شد',
|
||||||
|
text: 'تنظیمات قیمت با موفقیت بروزرسانی شد',
|
||||||
|
timer: 1500,
|
||||||
|
showConfirmButton: false,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = extractApiErrorMessage(error, 'ذخیره تنظیمات قیمت انجام نشد')
|
||||||
|
await Swal.fire({
|
||||||
|
icon: 'error',
|
||||||
|
title: 'خطا',
|
||||||
|
text: errorMessage.value,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadPricingSettings()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|||||||
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: {
|
server: {
|
||||||
port: 5173,
|
host: '0.0.0.0',
|
||||||
proxy: {
|
port: 5173
|
||||||
'/api': {
|
|
||||||
target: 'http://localhost:3000',
|
|
||||||
changeOrigin: true,
|
|
||||||
secure: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
Reference in New Issue
Block a user