Redesign product detail page and fix icon/cart reactivity bugs
- SingleProduct.vue: rebuild gallery/header/action bar as a self-scrolling layout with a sticky photo, solid-on-scroll header matching PageBar, and a full-screen image preview (blurred overlay, centered thumbnail strip) - Add GallerySlider.vue: pointer-based swipe carousel isolated in an LTR subtree, fixing RTL/transform sign mismatch that caused slide jumps - Fix Icon.vue: icon was read into a plain ref once at setup and never updated on prop change; switch to computed so icon swaps (e.g. cart qty stepper trash/minus toggle) actually re-render - Fix quantity stepper DOM order in SingleProduct.vue/CartItem.vue so plus/minus render correctly under RTL flex - prisma/assign-downloaded-images.js: seed real product photos (FEATURED/ FEATURED_MOBILE/GALLERY/GALLERY_MOBILE) onto the 5 placeholder products
This commit is contained in:
@@ -42,6 +42,16 @@ ax.interceptors.request.use(
|
||||
} else {
|
||||
delete config.headers['Authorization'];
|
||||
}
|
||||
// The instance-level default Content-Type is 'application/json', and axios
|
||||
// only auto-sets multipart/form-data + boundary when no Content-Type is
|
||||
// already present — an existing default is NOT replaced. Posting a
|
||||
// FormData body (file uploads) with the JSON default left in place sends
|
||||
// the raw FormData object as a JSON-labelled body, so multer/NestJS's
|
||||
// FileInterceptor sees no file at all and 400s. Let axios/the browser set
|
||||
// the correct multipart boundary for FormData bodies instead.
|
||||
if (config.data instanceof FormData) {
|
||||
delete config.headers['Content-Type'];
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error),
|
||||
|
||||
@@ -10,11 +10,77 @@
|
||||
Fields with no column in the redesigned schema are NOT rendered rather than
|
||||
collected and silently dropped — listed in docs/ADMIN-PORT-PLAN.md:
|
||||
delivery_type, message_long_delivery, offer_day, has_warranty, is_exist,
|
||||
tags, the two image uploaders, and the variant colour/rial-wage fields.
|
||||
tags, and the variant colour/rial-wage fields.
|
||||
|
||||
The image uploaders ARE rendered (unlike the note above once claimed):
|
||||
Product.images is a real ProductImage[] relation and `file/create` is a real,
|
||||
already-wired endpoint (used by category/slider/avatar uploads), so leaving
|
||||
product images unbuilt was a gap, not a schema/endpoint limitation.
|
||||
|
||||
Legacy (AddProduct.vue:171-271) had 4 distinct image slots, not one generic
|
||||
list — preserved here as 4 sections, each tagged with its own
|
||||
ProductImageRole so the API can tell them apart:
|
||||
تصویر شاخص (indicator_id) — single, site featured image
|
||||
تصویر شاخص موبایل (mobile_pic) — single, mobile featured image
|
||||
گالری تصاویر سایت (images) — multiple, site gallery
|
||||
گالری تصاویر موبایل (images_mobile)— multiple, mobile gallery
|
||||
Legacy's 5th slot ("پادکست", an audio upload) is not an image and is out of
|
||||
scope here. Legacy's per-gallery-image caption field (`imageBody`) has no
|
||||
column on ProductImage and is dropped, same as the other schema-less fields
|
||||
noted above.
|
||||
-->
|
||||
<div id="page-user-view">
|
||||
<a-form layout="vertical" @submit.prevent="handleSubmit">
|
||||
<a-spin :spinning="loading">
|
||||
<a-card title="تصاویر محصول" class="mb-base">
|
||||
<div class="grid-2">
|
||||
<a-form-item label="تصویر شاخص">
|
||||
<ImageUploadInput v-model="form.indicator_id" purpose="product.indicator" placeholder="تصویر شاخص" />
|
||||
</a-form-item>
|
||||
<a-form-item label="تصویر شاخص موبایل">
|
||||
<ImageUploadInput v-model="form.mobile_pic" purpose="product.indicator_mobile" placeholder="تصویر شاخص موبایل" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
|
||||
<a-form-item label="گالری تصاویر سایت">
|
||||
<div class="image-grid">
|
||||
<div v-for="(url, i) in gallerySite" :key="i" class="image-grid__item">
|
||||
<ImageUploadInput
|
||||
:model-value="url"
|
||||
purpose="product.gallery"
|
||||
placeholder="تصویر"
|
||||
@update:model-value="(v) => (v ? (gallerySite[i] = v) : gallerySite.splice(i, 1))"
|
||||
/>
|
||||
</div>
|
||||
<ImageUploadInput
|
||||
:model-value="null"
|
||||
purpose="product.gallery"
|
||||
placeholder="افزودن تصویر"
|
||||
@update:model-value="(v) => v && gallerySite.push(v)"
|
||||
/>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="گالری تصاویر موبایل">
|
||||
<div class="image-grid">
|
||||
<div v-for="(url, i) in galleryMobile" :key="i" class="image-grid__item">
|
||||
<ImageUploadInput
|
||||
:model-value="url"
|
||||
purpose="product.gallery_mobile"
|
||||
placeholder="تصویر"
|
||||
@update:model-value="(v) => (v ? (galleryMobile[i] = v) : galleryMobile.splice(i, 1))"
|
||||
/>
|
||||
</div>
|
||||
<ImageUploadInput
|
||||
:model-value="null"
|
||||
purpose="product.gallery_mobile"
|
||||
placeholder="افزودن تصویر"
|
||||
@update:model-value="(v) => v && galleryMobile.push(v)"
|
||||
/>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-card>
|
||||
|
||||
<a-card title="اطلاعات پایه" class="mb-base">
|
||||
<div class="grid-3">
|
||||
<a-form-item label="کد محصول">
|
||||
@@ -169,6 +235,7 @@ import { message } from 'ant-design-vue';
|
||||
import { DeleteOutlined, EditOutlined } from '@ant-design/icons-vue';
|
||||
import { useUsersStore } from '../../../stores/users';
|
||||
import api from '../../../api/client';
|
||||
import ImageUploadInput from '../../../components/upload/ImageUploadInput.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -191,10 +258,17 @@ const form = reactive<Record<string, any>>({
|
||||
ciny_ids: [] as string[],
|
||||
body_html: '',
|
||||
is_active: true,
|
||||
// ── the 2 single-image slots — see the template's image-section comment
|
||||
indicator_id: null as string | null,
|
||||
mobile_pic: null as string | null,
|
||||
});
|
||||
|
||||
const err = reactive<Record<string, string>>({});
|
||||
|
||||
// ── the 2 gallery slots (lists of URLs), edited in memory, saved with the product
|
||||
const gallerySite = ref<string[]>([]);
|
||||
const galleryMobile = ref<string[]>([]);
|
||||
|
||||
// ── variants (edited in memory, saved with the product)
|
||||
interface VariantRow {
|
||||
__key: string;
|
||||
@@ -277,6 +351,8 @@ async function handleSubmit() {
|
||||
const payload = {
|
||||
...form,
|
||||
cats: form.category_id ? [form.category_id] : [],
|
||||
images: gallerySite.value,
|
||||
images_mobile: galleryMobile.value,
|
||||
variants: variants.value.map((v) => ({
|
||||
id: v.id,
|
||||
title: v.title,
|
||||
@@ -333,7 +409,11 @@ onMounted(async () => {
|
||||
ciny_ids: data.ciny_ids ?? [],
|
||||
body_html: data.body_html ?? '',
|
||||
is_active: data.is_active ?? true,
|
||||
indicator_id: data.indicator_id ?? null,
|
||||
mobile_pic: data.mobile_pic ?? null,
|
||||
});
|
||||
gallerySite.value = data.images_gallery ?? [];
|
||||
galleryMobile.value = data.images_mobile ?? [];
|
||||
variants.value = (data.variants_all ?? []).map((v: any, i: number) => ({
|
||||
__key: v.id ?? `v-${i}`,
|
||||
id: v.id,
|
||||
@@ -357,6 +437,11 @@ onMounted(async () => {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.image-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user