diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..75aaabe --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +VITE_API_BASE_URL=https://parsshop-back.mugit.ir/api diff --git a/src/components/admin/products/AdminProductCreateForm.vue b/src/components/admin/products/AdminProductCreateForm.vue index 3e092c8..7c56b02 100644 --- a/src/components/admin/products/AdminProductCreateForm.vue +++ b/src/components/admin/products/AdminProductCreateForm.vue @@ -5,11 +5,11 @@
- ایجاد محصول + {{ pageBadgeLabel }}
-

افزودن محصول جدید

+

{{ pageTitle }}

- فرم به چند باکس مشخص تقسیم شده تا اطلاعات پایه، محتوا، سئو، رسانه و ویژگی‌ها از هم جدا باشند و صفحه شلوغ نشود. + {{ pageDescription }}

@@ -17,7 +17,7 @@ بازگشت به لیست @@ -121,52 +121,90 @@ -
-
- - -
- نمای درختی - +
+
نوع انتخاب‌شده
+
{{ selectedProductTypeLabel }}
+
{{ filteredCategories.length }} دسته‌بندی و {{ filteredBrands.length }} برند برای این نوع در دسترس است.
- -
-
- +
+ +
+
+
+ +

از میان ساختار درختی دسته‌ها، مناسب‌ترین گزینه نهایی را برای محصول انتخاب کنید.

-
- دسته‌بندی مطابق جستجو پیدا نشد. + +
+ +
+ + +
+
+ +
+
+ دسته‌بندی مطابق جستجو پیدا نشد. +
+
+ +
+
انتخاب فعلی
+
{{ selectedCategoryLabel || 'هنوز دسته‌بندی انتخاب نشده است' }}
+
+ مسیر: {{ selectedCategoryTrail.join(' / ') }} +
-

انتخاب فعلی: {{ selectedCategoryLabel }}

-
- - -
-
- - -

Fallback brand: {{ form.brand }}

+ +
+
وضعیت برند
+
{{ selectedBrand?.name || 'برندی انتخاب نشده است' }}
+
مقدار جایگزین فعلی: {{ form.brand }}
+
@@ -260,7 +298,7 @@
- + @@ -281,17 +319,15 @@
-
-
گالری تصاویر
-
می‌توانید فایل‌های آپلودی و آیتم‌های Media Library را با هم ترکیب کنید
+
تصاویر گالری فقط از Media Library انتخاب می‌شوند
{{ galleryAssets.length }} آیتم @@ -314,10 +350,8 @@
-
-
@@ -383,10 +417,8 @@
-
-
@@ -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([]) const brands = ref([]) const reusableAttributes = ref([]) -const attributeRows = ref([createEmptyAttributeRow()]) +const attributeRows = ref([createEmptyAttributeRow(0)]) const tagInput = ref('') const validationErrors = ref([]) 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(null) -const galleryInput = ref(null) -const modelInput = ref(null) const mainImageFile = ref(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(() => { const query = categorySearch.value.trim().toLowerCase() @@ -772,6 +837,13 @@ const visibleCategoryRows = computed(() => { }) 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(() => { @@ -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((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(() => { diff --git a/src/components/admin/products/ProductAttributesBuilder.vue b/src/components/admin/products/ProductAttributesBuilder.vue index bd22d0e..6c0e415 100644 --- a/src/components/admin/products/ProductAttributesBuilder.vue +++ b/src/components/admin/products/ProductAttributesBuilder.vue @@ -6,6 +6,10 @@ ویژگی‌ها از API خوانده می‌شوند و هر ردیف فقط یک ویژگی را مدیریت می‌کند. +
+ دو ویژگی اول، در لیست محصولات سایت نمایش داده می‌شوند. برای تعیین ترتیب نمایش، ردیف‌ها را با دکمه‌های بالا و پایین جابه‌جا کنید. +
+
@@ -18,94 +22,123 @@
-
-
-
ویژگی {{ index + 1 }}
-
{{ getAttribute(row.attributeId)?.slug || 'ویژگی انتخاب نشده' }}
+ -
+ + -
-
- - -
-
- - -
-
- -
-
- {{ dataTypeLabelMap[getAttribute(row.attributeId)!.dataType] }} - {{ getAttribute(row.attributeId)?.unit }} - فیلترپذیر - قابل نمایش -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- -
+
+
+
{{ getAttribute(row.attributeId)?.slug || '' }}
+
+ +
-
- - +
+
+ + +
+
+ + +
-
-
- ابتدا ویژگی reusable را انتخاب کنید. +
+
+ {{ dataTypeLabelMap[getAttribute(row.attributeId)!.dataType] }} + {{ getAttribute(row.attributeId)?.unit }} + فیلترپذیر + قابل نمایش +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+
+ +
+ + +
+
+ +
+ ابتدا ویژگی reusable را انتخاب کنید. +
@@ -113,6 +146,9 @@ diff --git a/src/components/admin/products/ProductForm.vue b/src/components/admin/products/ProductForm.vue index c99d33b..2fa6aec 100644 --- a/src/components/admin/products/ProductForm.vue +++ b/src/components/admin/products/ProductForm.vue @@ -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 'قیمت فروش نمی تواند منفی باشد' diff --git a/src/components/layout/Sidebar.vue b/src/components/layout/Sidebar.vue index c0627e9..5b01fc3 100644 --- a/src/components/layout/Sidebar.vue +++ b/src/components/layout/Sidebar.vue @@ -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' }, ], diff --git a/src/config/api.ts b/src/config/api.ts new file mode 100644 index 0000000..9cec43d --- /dev/null +++ b/src/config/api.ts @@ -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}`) diff --git a/src/router/index.ts b/src/router/index.ts index 40f22b2..3660917 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -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', 'تنظیمات سئو', 'مدیریت تنظیمات فنی و محتوایی سئو فروشگاه'), { diff --git a/src/services/admin-api.ts b/src/services/admin-api.ts index 76c50c1..8937fdb 100644 --- a/src/services/admin-api.ts +++ b/src/services/admin-api.ts @@ -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) { diff --git a/src/services/api.ts b/src/services/api.ts index 1fa3dcc..6204c36 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -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' diff --git a/src/services/payment-service.ts b/src/services/payment-service.ts new file mode 100644 index 0000000..74ef715 --- /dev/null +++ b/src/services/payment-service.ts @@ -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 { + return apiService.get('/admin/payments/methods') + } + + async updatePaymentMethod(code: PaymentMethodCode, payload: UpdatePaymentMethodPayload): Promise { + return apiService.patch(`/admin/payments/methods/${code}`, payload) + } + + async getBankSlipOrders(): Promise { + return apiService.get('/admin/payments/bank-slip/orders') + } + + async reviewBankSlipOrder(orderId: string, payload: ReviewBankSlipPayload): Promise { + return apiService.patch(`/admin/payments/bank-slip/orders/${orderId}/review`, payload) + } +} + +export const paymentService = new PaymentService() +export default paymentService diff --git a/src/services/settings-service.ts b/src/services/settings-service.ts new file mode 100644 index 0000000..fa7dbe2 --- /dev/null +++ b/src/services/settings-service.ts @@ -0,0 +1,15 @@ +import apiService from '@/services/api' +import type { PricingSettings, UpdatePricingSettingsPayload } from '@/types/settings' + +class SettingsService { + async getPricingSettings(): Promise { + return apiService.get('/admin/settings/pricing') + } + + async updatePricingSettings(payload: UpdatePricingSettingsPayload): Promise { + return apiService.patch('/admin/settings/pricing', payload) + } +} + +export const settingsService = new SettingsService() +export default settingsService diff --git a/src/types/admin-product-create.ts b/src/types/admin-product-create.ts index a4d4eed..4dc72e5 100644 --- a/src/types/admin-product-create.ts +++ b/src/types/admin-product-create.ts @@ -1,6 +1,7 @@ export interface ProductAttributeFormRow { id: string attributeId: string + displayOrder: number valueText: string valueNumber: string valueBoolean: boolean diff --git a/src/types/payment.ts b/src/types/payment.ts new file mode 100644 index 0000000..11cbc9b --- /dev/null +++ b/src/types/payment.ts @@ -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 | 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 +} diff --git a/src/types/product.ts b/src/types/product.ts index 7bafd27..dfaf8cf 100644 --- a/src/types/product.ts +++ b/src/types/product.ts @@ -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 diff --git a/src/types/settings.ts b/src/types/settings.ts new file mode 100644 index 0000000..5950689 --- /dev/null +++ b/src/types/settings.ts @@ -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 +} diff --git a/src/views/admin/AdminPaymentBankSlips.vue b/src/views/admin/AdminPaymentBankSlips.vue new file mode 100644 index 0000000..6b74e02 --- /dev/null +++ b/src/views/admin/AdminPaymentBankSlips.vue @@ -0,0 +1,160 @@ + + + diff --git a/src/views/admin/AdminProductEdit.vue b/src/views/admin/AdminProductEdit.vue index 6d37dc7..afe2848 100644 --- a/src/views/admin/AdminProductEdit.vue +++ b/src/views/admin/AdminProductEdit.vue @@ -1,10 +1,10 @@ diff --git a/src/views/admin/AdminSettingsPaymentGateways.vue b/src/views/admin/AdminSettingsPaymentGateways.vue new file mode 100644 index 0000000..1d35b66 --- /dev/null +++ b/src/views/admin/AdminSettingsPaymentGateways.vue @@ -0,0 +1,378 @@ + + + diff --git a/vite.config.ts b/vite.config.ts index ad360b5..31b53d9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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 } -}) \ No newline at end of file +})