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
+134
View File
@@ -0,0 +1,134 @@
// One-off data fix: the 5 hand-authored demo products (product-classic-ring,
// product-woven-bracelet, product-pendant-necklace, product-hoop-earrings,
// product-full-coin) each had exactly one ProductImage row pointing at
// /img/no-items.png — a missing-image placeholder, not a real photo. This
// replaces those with a real, varied set drawn from the 15 jewelry photos the
// user pointed at (originally in "Downloads/New folder", already copied into
// storage/uploads/ under their original filenames — confirmed present before
// this script was written).
//
// Distribution, mirroring the one product in this DB that already has a full,
// correctly-roled image set (`شسی`, id cmu4ia4iq0008s4zhwrz6wy3z):
// - exactly one FEATURED (تصویر شاخص وب)
// - exactly one FEATURED_MOBILE (تصویر شاخص موبایل)
// - 2–3 GALLERY / GALLERY_MOBILE, split across both roles
//
// CHECKED, not assumed: CatalogService.listProducts() (apps/api/src/modules/
// catalog/catalog.service.ts) returns every ProductImage row unfiltered —
// there is no role-based split on the read side today. So on the user-app
// side ALL of these rows end up in ProductSummary.images and all of them are
// swipeable in the gallery/preview — the FEATURED/FEATURED_MOBILE role still
// matters (it is what the admin panel and any future "hero image" logic reads
// by role), but right now it does not narrow what the shopper-facing gallery
// shows. `sortOrder` is set to the creation index below, which is what
// actually determines swipe order today, since nothing filters by role.
//
// Assignment across the 5 products is a random, non-repeating draw per
// product from the 15-photo pool (Fisher-Yates, reseedable via IMG_SEED for a
// reproducible run) — the same photo can end up on more than one product
// across different products (15 photos split ~4 ways per product exceeds the
// pool), which is fine: this is demo data standing in for real per-product
// photography, not a claim that these are 5 distinct real items.
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
// The 15 files confirmed present in storage/uploads/ under their original
// names before this script ran.
const POOL = [
'152_5e1435ca-05a5-4146-a4ea-89726650c29c.webp',
'59_0661f668-5d1d-4c05-87fd-50fd65cd8383.webp',
'71085964.png',
'79A202-1201_01.webp',
'Brooches-Vintage-Jewelry-full-1-322_60.60-9ed8e81b.webp',
'MB116662_Front_1080p_f364da1d-6019-4ca4-8dd8-a323387634c7.webp',
'TEMP_360x360.webp',
'channel_setting_480x480.webp',
'engagement-ring-in-18k-gold-with-diamonds-nr532-white-18k-semi-white-205224.webp',
'finished-jewelry.webp',
'kisspng-earring-gold-jewellery-ring-new-22ct-yellow-gold-ring-armari-45-sku27555-5d0796cb466539.4977043815607784432884-768x768.png',
'pl-background-free-product-photography-jewelery-3.webp',
'round-cut-engagement-ring-in-18k-gold-with-diamonds-lr3011-2t-18k-x-2t-964060.webp',
'td4-aam-n0_dd28f69d-9e3c-490d-be37-1ba074737992.webp',
'w=640,h=640,fit=crop.avif',
];
const PRODUCT_IDS = [
'product-classic-ring',
'product-woven-bracelet',
'product-pendant-necklace',
'product-hoop-earrings',
'product-full-coin',
];
// Simple seeded PRNG (mulberry32) so a re-run with the same IMG_SEED is
// reproducible — useful for re-checking a specific assignment without the
// draw changing under you.
function mulberry32(seed) {
return function () {
seed |= 0;
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const seed = Number(process.env.IMG_SEED) || Date.now();
const rand = mulberry32(seed);
function shuffled(arr) {
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
async function main() {
console.log(`seed=${seed}`);
for (const productId of PRODUCT_IDS) {
const product = await prisma.product.findUnique({ where: { id: productId } });
if (!product) {
console.log(`skip ${productId}: not found`);
continue;
}
// 3–5 photos per product, drawn without replacement from the pool.
const count = 3 + Math.floor(rand() * 3); // 3, 4, or 5
const picks = shuffled(POOL).slice(0, count);
// Role plan: first pick is FEATURED, second is FEATURED_MOBILE, the rest
// alternate GALLERY / GALLERY_MOBILE — same shape as the شسی product's
// existing 8-image set, just proportioned to a smaller pick count.
const roles = ['FEATURED', 'FEATURED_MOBILE'];
for (let i = 2; i < picks.length; i++) {
roles.push(i % 2 === 0 ? 'GALLERY' : 'GALLERY_MOBILE');
}
// Replace whatever this product currently has (the single no-items.png
// placeholder row) rather than appending alongside it.
await prisma.productImage.deleteMany({ where: { productId } });
for (let i = 0; i < picks.length; i++) {
await prisma.productImage.create({
data: {
id: `${productId}-img-${i + 1}`,
productId,
url: `/uploads/${picks[i]}`,
sortOrder: i,
role: roles[i],
},
});
}
console.log(`${productId}: ${picks.length} images -> ${picks.map((p, i) => `${roles[i]}:${p}`).join(', ')}`);
}
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
@@ -0,0 +1,8 @@
-- Placeholder: the original migration.sql for this migration was lost from
-- disk (directory existed but was empty) while the database's
-- _prisma_migrations ledger already recorded it as applied and finished.
-- Recreated as an empty no-op file, then registered via
-- `prisma migrate resolve --applied`, so Prisma tooling stops blocking on
-- P3015 without re-running or guessing at SQL that already ran against this
-- database. The actual schema effect (CustomerKind.RETAIL) is present in
-- schema.prisma and already exists in the live database.
@@ -0,0 +1,12 @@
-- Placeholder: this migration is recorded as applied in the database's
-- _prisma_migrations ledger, but no migration.sql for it ever existed on
-- disk in this checkout (added Customer.firstName/lastName/productPercent
-- directly against the live DB, schema.prisma was never regenerated from it
-- as a tracked migration). Recreated as an empty no-op file purely so Prisma
-- CLI tooling (migrate status/dev) stops blocking on a missing-migration
-- error. Does not re-run or alter anything — the columns already exist live.
--
-- NOT reconciled with schema.prisma: Customer.firstName, Customer.lastName,
-- Customer.productPercent exist in the database but have no field in
-- prisma/schema.prisma's Customer model. This is a pre-existing drift,
-- separate from this change, and should be looked at on its own.
@@ -0,0 +1,12 @@
-- Placeholder: this migration is recorded as applied in the database's
-- _prisma_migrations ledger, but no migration.sql for it ever existed on
-- disk in this checkout (added Customer.parentAgentId + Customer.birthDate
-- directly against the live DB; schema.prisma was never regenerated from it
-- as a tracked migration). Recreated as an empty no-op file purely so Prisma
-- CLI tooling (migrate status/dev) stops blocking on a missing-migration
-- error. Does not re-run or alter anything — the columns already exist live.
--
-- NOT reconciled with schema.prisma: Customer.parentAgentId,
-- Customer.birthDate exist in the database but have no field in
-- prisma/schema.prisma's Customer model. This is a pre-existing drift,
-- separate from this change, and should be looked at on its own.
@@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "ProductImageRole" AS ENUM ('FEATURED', 'FEATURED_MOBILE', 'GALLERY', 'GALLERY_MOBILE');
-- AlterTable
ALTER TABLE "ProductImage" ADD COLUMN "role" "ProductImageRole" NOT NULL DEFAULT 'GALLERY';
+14 -3
View File
@@ -320,11 +320,22 @@ model Product {
}
model ProductImage {
id String @id @default(cuid())
id String @id @default(cuid())
productId String
product Product @relation(fields: [productId], references: [id])
product Product @relation(fields: [productId], references: [id])
url String
sortOrder Int @default(0)
sortOrder Int @default(0)
/// Legacy had 4 distinct upload slots (تصویر شاخص / تصویر شاخص موبایل /
/// گالری تصاویر سایت / گالری تصاویر موبایل) — GALLERY is the default so
/// existing rows and any generic "just attach an image" caller keep working.
role ProductImageRole @default(GALLERY)
}
enum ProductImageRole {
FEATURED
FEATURED_MOBILE
GALLERY
GALLERY_MOBILE
}
model ProductVariant {
@@ -115,10 +115,16 @@ export class AdminProductsService {
: null,
// legacy grid slot reads `ProductImage`
ProductImage: p.images.map((i) => ({ id: i.id, image: i.url, full: i.url, url: i.url })),
images: p.images.map((i) => ({ id: i.id, url: i.url, sortOrder: i.sortOrder })),
images: p.images.map((i) => ({ id: i.id, url: i.url, sortOrder: i.sortOrder, role: i.role })),
// The order builder's grid reads `thumb.full` and `variants_all` directly
// off each row, so both travel with the list as they did in legacy.
thumb: p.images[0] ? { full: p.images[0].url } : null,
// off each row, so both travel with the list as they did in legacy. Legacy's
// grid thumbnail was always the featured image (تصویر شاخص) when one was
// set — fall back to the first gallery image only if no featured image
// was uploaded, rather than picking whichever image happens to sort first.
thumb: (() => {
const featured = p.images.find((i) => i.role === 'FEATURED') ?? p.images[0];
return featured ? { full: featured.url } : null;
})(),
variants_all: p.variants.map((v) => this.variantRow(v)),
variants_count: p.variants.length,
// legacy min_gram / max_gram: the product's lightest and heaviest variant,
@@ -160,6 +166,13 @@ export class AdminProductsService {
cats: p.categoryId ? [p.categoryId] : [],
ciny_ids: p.collections.map((c) => c.collectionId),
variants_all: p.variants.map((v) => this.variantRow(v)),
// The 4 image slots the product form edits, split back out by role —
// mirrors the shape AddProduct.vue posts (indicator_id / mobile_pic /
// images / images_mobile).
indicator_id: p.images.find((i) => i.role === 'FEATURED')?.url ?? null,
mobile_pic: p.images.find((i) => i.role === 'FEATURED_MOBILE')?.url ?? null,
images_gallery: p.images.filter((i) => i.role === 'GALLERY').map((i) => i.url),
images_mobile: p.images.filter((i) => i.role === 'GALLERY_MOBILE').map((i) => i.url),
};
}
@@ -285,6 +298,36 @@ export class AdminProductsService {
};
}
/**
* Legacy had 4 distinct upload slots (تصویر شاخص / تصویر شاخص موبایل /
* گالری تصاویر سایت / گالری تصاویر موبایل — AddProduct.vue's `indicator_id`,
* `mobile_pic`, `images`, and a mobile gallery). The form posts one URL each
* for the two featured slots and one URL array each for the two galleries;
* this flattens all four into the role-tagged rows ProductImage.create wants.
*/
private imageCreateData(data: Record<string, unknown>): { url: string; sortOrder: number; role: Prisma.ProductImageCreateInput['role'] }[] {
const rows: { url: string; sortOrder: number; role: Prisma.ProductImageCreateInput['role'] }[] = [];
const single = (v: unknown, role: Prisma.ProductImageCreateInput['role']) => {
const url = typeof v === 'string' ? v : String((v as Record<string, unknown>)?.url ?? '');
if (url) rows.push({ url, sortOrder: 0, role });
};
const gallery = (v: unknown, role: Prisma.ProductImageCreateInput['role']) => {
if (!Array.isArray(v)) return;
(v as unknown[]).forEach((item, i) => {
const url = typeof item === 'string' ? item : String((item as Record<string, unknown>)?.url ?? '');
if (url) rows.push({ url, sortOrder: i, role });
});
};
single(data.indicator_id, 'FEATURED');
single(data.mobile_pic, 'FEATURED_MOBILE');
gallery(data.images, 'GALLERY');
gallery(data.images_mobile, 'GALLERY_MOBILE');
return rows;
}
/** Legacy `POST product/create`. */
async create(data: Record<string, unknown>) {
const title = String(data.title ?? '').trim();
@@ -295,6 +338,7 @@ export class AdminProductsService {
const variants = Array.isArray(data.variants)
? (data.variants as Record<string, unknown>[])
: [];
const images = this.imageCreateData(data);
return this.prisma.product.create({
data: {
@@ -311,8 +355,9 @@ export class AdminProductsService {
variants: variants.length
? { create: variants.map((v) => this.variantCreateData(v)) }
: undefined,
images: images.length ? { create: images } : undefined,
},
include: { variants: true },
include: { variants: true, images: true },
});
}
@@ -371,6 +416,21 @@ export class AdminProductsService {
});
}
// Images carry no downstream reference (unlike variants/OrderItem), so all
// 4 slots are applied as a straight replace: clear and recreate from
// whatever the form posted. The product form always posts all 4 image
// fields together (even as empty/absent), so any one of them present means
// this is a real submit, not a partial PATCH missing the others.
if (['indicator_id', 'mobile_pic', 'images', 'images_mobile'].some((k) => k in data)) {
const images = this.imageCreateData(data);
await this.prisma.productImage.deleteMany({ where: { productId: id } });
if (images.length) {
await this.prisma.productImage.createMany({
data: images.map((img) => ({ ...img, productId: id })),
});
}
}
return this.findOne(id);
}