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

1
.env.example Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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