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:
s.esmaeili
2026-09-18 16:22:31 +03:30
parent 53a304b5d5
commit b80fa9bcfb
73 changed files with 1035 additions and 180 deletions
@@ -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;
}