Stage M1
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Locale" AS ENUM ('VI', 'EN');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "brand_translations" (
|
||||
"brand_id" UUID NOT NULL,
|
||||
"locale" "Locale" NOT NULL,
|
||||
"name" VARCHAR(160) NOT NULL,
|
||||
"slug" VARCHAR(180) NOT NULL,
|
||||
"description" TEXT,
|
||||
"meta_title" VARCHAR(255),
|
||||
"meta_description" TEXT,
|
||||
|
||||
CONSTRAINT "brand_translations_pkey" PRIMARY KEY ("brand_id","locale")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "category_translations" (
|
||||
"category_id" UUID NOT NULL,
|
||||
"locale" "Locale" NOT NULL,
|
||||
"name" VARCHAR(160) NOT NULL,
|
||||
"slug" VARCHAR(180) NOT NULL,
|
||||
"description" TEXT,
|
||||
"meta_title" VARCHAR(255),
|
||||
"meta_description" TEXT,
|
||||
|
||||
CONSTRAINT "category_translations_pkey" PRIMARY KEY ("category_id","locale")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "collection_translations" (
|
||||
"collection_id" UUID NOT NULL,
|
||||
"locale" "Locale" NOT NULL,
|
||||
"name" VARCHAR(160) NOT NULL,
|
||||
"slug" VARCHAR(180) NOT NULL,
|
||||
"description" TEXT,
|
||||
"meta_title" VARCHAR(255),
|
||||
"meta_description" TEXT,
|
||||
|
||||
CONSTRAINT "collection_translations_pkey" PRIMARY KEY ("collection_id","locale")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "product_translations" (
|
||||
"product_id" UUID NOT NULL,
|
||||
"locale" "Locale" NOT NULL,
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
"slug" VARCHAR(280) NOT NULL,
|
||||
"short_description" VARCHAR(500),
|
||||
"description" TEXT,
|
||||
"meta_title" VARCHAR(255),
|
||||
"meta_description" TEXT,
|
||||
|
||||
CONSTRAINT "product_translations_pkey" PRIMARY KEY ("product_id","locale")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "product_option_translations" (
|
||||
"option_id" UUID NOT NULL,
|
||||
"locale" "Locale" NOT NULL,
|
||||
"name" VARCHAR(60) NOT NULL,
|
||||
|
||||
CONSTRAINT "product_option_translations_pkey" PRIMARY KEY ("option_id","locale")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "product_option_value_translations" (
|
||||
"option_value_id" UUID NOT NULL,
|
||||
"locale" "Locale" NOT NULL,
|
||||
"label" VARCHAR(80) NOT NULL,
|
||||
|
||||
CONSTRAINT "product_option_value_translations_pkey" PRIMARY KEY ("option_value_id","locale")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "brand_translations_locale_slug_key" ON "brand_translations"("locale", "slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "category_translations_locale_slug_key" ON "category_translations"("locale", "slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "collection_translations_locale_slug_key" ON "collection_translations"("locale", "slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "product_translations_locale_name_idx" ON "product_translations"("locale", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "product_translations_locale_slug_key" ON "product_translations"("locale", "slug");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "brand_translations" ADD CONSTRAINT "brand_translations_brand_id_fkey" FOREIGN KEY ("brand_id") REFERENCES "brands"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "category_translations" ADD CONSTRAINT "category_translations_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "collection_translations" ADD CONSTRAINT "collection_translations_collection_id_fkey" FOREIGN KEY ("collection_id") REFERENCES "collections"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "product_translations" ADD CONSTRAINT "product_translations_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "product_option_translations" ADD CONSTRAINT "product_option_translations_option_id_fkey" FOREIGN KEY ("option_id") REFERENCES "product_options"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "product_option_value_translations" ADD CONSTRAINT "product_option_value_translations_option_value_id_fkey" FOREIGN KEY ("option_value_id") REFERENCES "product_option_values"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "products" ADD COLUMN "in_stock" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "is_on_sale" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "max_price_amount" INTEGER,
|
||||
ADD COLUMN "min_price_amount" INTEGER;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "products_status_min_price_amount_idx" ON "products"("status", "min_price_amount");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "products_status_in_stock_idx" ON "products"("status", "in_stock");
|
||||
@@ -0,0 +1,12 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "product_attribute_translations" (
|
||||
"attribute_id" UUID NOT NULL,
|
||||
"locale" "Locale" NOT NULL,
|
||||
"label" VARCHAR(120) NOT NULL,
|
||||
"value" VARCHAR(500) NOT NULL,
|
||||
|
||||
CONSTRAINT "product_attribute_translations_pkey" PRIMARY KEY ("attribute_id","locale")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "product_attribute_translations" ADD CONSTRAINT "product_attribute_translations_attribute_id_fkey" FOREIGN KEY ("attribute_id") REFERENCES "product_attributes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
+215
-51
@@ -1,13 +1,13 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sport Store — Prisma schema
|
||||
//
|
||||
// SCOPE OF THIS FILE (milestone 0)
|
||||
// SCOPE OF THIS FILE (milestones 0-1)
|
||||
// Identity + RBAC, catalog (Product / ProductVariant / options / images /
|
||||
// attributes), taxonomy (brand / category / collection), media metadata and
|
||||
// the inventory ledger.
|
||||
// attributes), taxonomy (brand / category / collection), per-locale content
|
||||
// translations, media metadata and the inventory ledger.
|
||||
//
|
||||
// Cart, checkout, order, payment, promotion and review tables are milestone 1.
|
||||
// They are intentionally absent so the first migration stays reviewable.
|
||||
// Cart, checkout, order, payment, promotion and review tables are milestone 5.
|
||||
// They are intentionally absent so the migration history stays reviewable.
|
||||
//
|
||||
// CONVENTIONS
|
||||
// - Table names: snake_case plural (@@map). Prisma models: PascalCase singular.
|
||||
@@ -50,11 +50,11 @@ enum UserStatus {
|
||||
/// The `type` column decides which token audience the account may authenticate
|
||||
/// against; RBAC decides what it may then do.
|
||||
model User {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
/// Always normalised to lowercase before write (see @sport/validation), so a
|
||||
/// plain unique index is enough and no citext extension is required.
|
||||
email String @unique @db.VarChar(255)
|
||||
passwordHash String? @map("password_hash")
|
||||
email String @unique @db.VarChar(255)
|
||||
passwordHash String? @map("password_hash")
|
||||
type UserType
|
||||
status UserStatus @default(ACTIVE)
|
||||
|
||||
@@ -70,7 +70,7 @@ model User {
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
|
||||
|
||||
avatar MediaAsset? @relation("UserAvatar", fields: [avatarId], references: [id], onDelete: SetNull)
|
||||
avatar MediaAsset? @relation("UserAvatar", fields: [avatarId], references: [id], onDelete: SetNull)
|
||||
roles UserRole[]
|
||||
sessions Session[]
|
||||
customer Customer?
|
||||
@@ -216,20 +216,20 @@ model Address {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
customerId String @map("customer_id") @db.Uuid
|
||||
|
||||
fullName String @map("full_name") @db.VarChar(160)
|
||||
phone String @db.VarChar(20)
|
||||
line1 String @db.VarChar(255)
|
||||
line2 String? @db.VarChar(255)
|
||||
fullName String @map("full_name") @db.VarChar(160)
|
||||
phone String @db.VarChar(20)
|
||||
line1 String @db.VarChar(255)
|
||||
line2 String? @db.VarChar(255)
|
||||
/// Vietnamese administrative divisions. Codes are kept alongside names so a
|
||||
/// later shipping-provider integration can map them without re-collecting.
|
||||
ward String? @db.VarChar(120)
|
||||
wardCode String? @map("ward_code") @db.VarChar(20)
|
||||
district String? @db.VarChar(120)
|
||||
ward String? @db.VarChar(120)
|
||||
wardCode String? @map("ward_code") @db.VarChar(20)
|
||||
district String? @db.VarChar(120)
|
||||
districtCode String? @map("district_code") @db.VarChar(20)
|
||||
province String @db.VarChar(120)
|
||||
province String @db.VarChar(120)
|
||||
provinceCode String? @map("province_code") @db.VarChar(20)
|
||||
countryCode String @default("VN") @map("country_code") @db.Char(2)
|
||||
postalCode String? @map("postal_code") @db.VarChar(20)
|
||||
countryCode String @default("VN") @map("country_code") @db.Char(2)
|
||||
postalCode String? @map("postal_code") @db.VarChar(20)
|
||||
|
||||
isDefaultShipping Boolean @default(false) @map("is_default_shipping")
|
||||
isDefaultBilling Boolean @default(false) @map("is_default_billing")
|
||||
@@ -257,16 +257,16 @@ enum MediaKind {
|
||||
/// stores binary. Public URLs are composed at read time from STORAGE_PUBLIC_URL
|
||||
/// + storageKey, so changing CDN or bucket is config, not a data migration.
|
||||
model MediaAsset {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
kind MediaKind @default(IMAGE)
|
||||
storageKey String @unique @map("storage_key") @db.VarChar(512)
|
||||
mimeType String @map("mime_type") @db.VarChar(120)
|
||||
sizeBytes Int @map("size_bytes")
|
||||
width Int?
|
||||
height Int?
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
kind MediaKind @default(IMAGE)
|
||||
storageKey String @unique @map("storage_key") @db.VarChar(512)
|
||||
mimeType String @map("mime_type") @db.VarChar(120)
|
||||
sizeBytes Int @map("size_bytes")
|
||||
width Int?
|
||||
height Int?
|
||||
/// Base64 LQIP, a few hundred bytes. Cheap enough to store inline.
|
||||
blurDataUrl String? @map("blur_data_url")
|
||||
altText String? @map("alt_text") @db.VarChar(255)
|
||||
blurDataUrl String? @map("blur_data_url")
|
||||
altText String? @map("alt_text") @db.VarChar(255)
|
||||
/// Free-form: original filename, uploader, EXIF subset, …
|
||||
metadata Json?
|
||||
|
||||
@@ -274,17 +274,37 @@ model MediaAsset {
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
|
||||
productImages ProductImage[]
|
||||
brandLogos Brand[] @relation("BrandLogo")
|
||||
categoryImages Category[] @relation("CategoryImage")
|
||||
collectionBanners Collection[] @relation("CollectionBanner")
|
||||
productImages ProductImage[]
|
||||
brandLogos Brand[] @relation("BrandLogo")
|
||||
categoryImages Category[] @relation("CategoryImage")
|
||||
collectionBanners Collection[] @relation("CollectionBanner")
|
||||
optionValueSwatches ProductOptionValue[] @relation("OptionValueSwatch")
|
||||
userAvatars User[] @relation("UserAvatar")
|
||||
userAvatars User[] @relation("UserAvatar")
|
||||
|
||||
@@index([kind, createdAt])
|
||||
@@map("media_assets")
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Localisation
|
||||
// ===========================================================================
|
||||
|
||||
enum Locale {
|
||||
VI
|
||||
EN
|
||||
}
|
||||
|
||||
// Translation tables follow one shape throughout: a composite (entityId, locale)
|
||||
// primary key, plus a per-locale unique slug where the entity has a URL.
|
||||
//
|
||||
// Typed tables rather than a generic (entityType, field, value) table: a generic
|
||||
// table cannot enforce "slug is unique within a locale", cannot be indexed
|
||||
// usefully, and returns `string | undefined` for everything. Six small tables
|
||||
// with real constraints beat one clever one.
|
||||
//
|
||||
// The base row keeps the canonical values. A missing translation falls back to
|
||||
// them field by field, so an untranslated description never renders as blank.
|
||||
|
||||
// ===========================================================================
|
||||
// Taxonomy
|
||||
// ===========================================================================
|
||||
@@ -303,8 +323,9 @@ model Brand {
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
logo MediaAsset? @relation("BrandLogo", fields: [logoId], references: [id], onDelete: SetNull)
|
||||
products Product[]
|
||||
logo MediaAsset? @relation("BrandLogo", fields: [logoId], references: [id], onDelete: SetNull)
|
||||
products Product[]
|
||||
translations BrandTranslation[]
|
||||
|
||||
@@map("brands")
|
||||
}
|
||||
@@ -333,10 +354,11 @@ model Category {
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: Restrict)
|
||||
children Category[] @relation("CategoryTree")
|
||||
image MediaAsset? @relation("CategoryImage", fields: [imageId], references: [id], onDelete: SetNull)
|
||||
products Product[]
|
||||
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: Restrict)
|
||||
children Category[] @relation("CategoryTree")
|
||||
image MediaAsset? @relation("CategoryImage", fields: [imageId], references: [id], onDelete: SetNull)
|
||||
products Product[]
|
||||
translations CategoryTranslation[]
|
||||
|
||||
@@unique([parentId, slug])
|
||||
@@index([path])
|
||||
@@ -371,8 +393,9 @@ model Collection {
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
banner MediaAsset? @relation("CollectionBanner", fields: [bannerId], references: [id], onDelete: SetNull)
|
||||
products ProductCollection[]
|
||||
banner MediaAsset? @relation("CollectionBanner", fields: [bannerId], references: [id], onDelete: SetNull)
|
||||
products ProductCollection[]
|
||||
translations CollectionTranslation[]
|
||||
|
||||
@@index([isActive, startsAt, endsAt])
|
||||
@@map("collections")
|
||||
@@ -453,19 +476,38 @@ model Product {
|
||||
metaDescription String? @map("meta_description")
|
||||
metadata Json?
|
||||
|
||||
/// ---- Read-model projection, derived from this product's variants --------
|
||||
///
|
||||
/// Denormalised deliberately. Sorting a listing by price and rendering a
|
||||
/// price facet both need MIN/MAX across variants, and no ORM can express
|
||||
/// "order by the minimum price of a related collection" — the alternatives
|
||||
/// were raw SQL for every listing query or a search index we do not need yet
|
||||
/// (ADR-0012).
|
||||
///
|
||||
/// These columns are ALWAYS derived, never authored. `ProductsService
|
||||
/// .recomputePricing()` is the single writer; every variant mutation in M3
|
||||
/// must call it. Treat a manual UPDATE of these columns as a bug.
|
||||
minPriceAmount Int? @map("min_price_amount")
|
||||
maxPriceAmount Int? @map("max_price_amount")
|
||||
isOnSale Boolean @default(false) @map("is_on_sale")
|
||||
inStock Boolean @default(false) @map("in_stock")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
|
||||
|
||||
brand Brand? @relation(fields: [brandId], references: [id], onDelete: SetNull)
|
||||
primaryCategory Category? @relation(fields: [primaryCategoryId], references: [id], onDelete: SetNull)
|
||||
brand Brand? @relation(fields: [brandId], references: [id], onDelete: SetNull)
|
||||
primaryCategory Category? @relation(fields: [primaryCategoryId], references: [id], onDelete: SetNull)
|
||||
options ProductOption[]
|
||||
variants ProductVariant[]
|
||||
images ProductImage[]
|
||||
attributes ProductAttribute[]
|
||||
collections ProductCollection[]
|
||||
translations ProductTranslation[]
|
||||
|
||||
@@index([status, publishedAt])
|
||||
@@index([status, minPriceAmount])
|
||||
@@index([status, inStock])
|
||||
@@index([brandId])
|
||||
@@index([primaryCategoryId])
|
||||
@@index([genderTargets], type: Gin)
|
||||
@@ -486,6 +528,7 @@ model ProductOption {
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
values ProductOptionValue[]
|
||||
variantLinks ProductVariantOptionValue[]
|
||||
translations ProductOptionTranslation[]
|
||||
|
||||
@@unique([productId, key])
|
||||
@@index([productId, position])
|
||||
@@ -504,10 +547,11 @@ model ProductOptionValue {
|
||||
swatchHex String? @map("swatch_hex") @db.VarChar(9)
|
||||
swatchImageId String? @map("swatch_image_id") @db.Uuid
|
||||
|
||||
option ProductOption @relation(fields: [optionId], references: [id], onDelete: Cascade)
|
||||
swatchImage MediaAsset? @relation("OptionValueSwatch", fields: [swatchImageId], references: [id], onDelete: SetNull)
|
||||
option ProductOption @relation(fields: [optionId], references: [id], onDelete: Cascade)
|
||||
swatchImage MediaAsset? @relation("OptionValueSwatch", fields: [swatchImageId], references: [id], onDelete: SetNull)
|
||||
variantLinks ProductVariantOptionValue[]
|
||||
images ProductImage[]
|
||||
translations ProductOptionValueTranslation[]
|
||||
|
||||
@@unique([optionId, value])
|
||||
@@index([optionId, position])
|
||||
@@ -525,13 +569,13 @@ model ProductVariant {
|
||||
/// Denormalised "Black / M" for display and for order-line snapshots.
|
||||
title String @db.VarChar(255)
|
||||
|
||||
currency Currency @default(VND)
|
||||
currency Currency @default(VND)
|
||||
/// All amounts are integers in the currency's minor unit.
|
||||
priceAmount Int @map("price_amount")
|
||||
salePriceAmount Int? @map("sale_price_amount")
|
||||
compareAtAmount Int? @map("compare_at_amount")
|
||||
priceAmount Int @map("price_amount")
|
||||
salePriceAmount Int? @map("sale_price_amount")
|
||||
compareAtAmount Int? @map("compare_at_amount")
|
||||
/// Landed cost — admin only, never serialised to the storefront.
|
||||
costAmount Int? @map("cost_amount")
|
||||
costAmount Int? @map("cost_amount")
|
||||
|
||||
weightGrams Int? @map("weight_grams")
|
||||
lengthMm Int? @map("length_mm")
|
||||
@@ -606,7 +650,8 @@ model ProductAttribute {
|
||||
position Int @default(0)
|
||||
isFilterable Boolean @default(false) @map("is_filterable")
|
||||
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
translations ProductAttributeTranslation[]
|
||||
|
||||
@@unique([productId, key])
|
||||
@@index([key, value])
|
||||
@@ -692,3 +737,122 @@ model StockMovement {
|
||||
@@index([referenceId])
|
||||
@@map("stock_movements")
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Translations
|
||||
// ===========================================================================
|
||||
|
||||
model BrandTranslation {
|
||||
brandId String @map("brand_id") @db.Uuid
|
||||
locale Locale
|
||||
|
||||
name String @db.VarChar(160)
|
||||
slug String @db.VarChar(180)
|
||||
description String?
|
||||
metaTitle String? @map("meta_title") @db.VarChar(255)
|
||||
metaDescription String? @map("meta_description")
|
||||
|
||||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([brandId, locale])
|
||||
@@unique([locale, slug])
|
||||
@@map("brand_translations")
|
||||
}
|
||||
|
||||
model CategoryTranslation {
|
||||
categoryId String @map("category_id") @db.Uuid
|
||||
locale Locale
|
||||
|
||||
name String @db.VarChar(160)
|
||||
slug String @db.VarChar(180)
|
||||
description String?
|
||||
metaTitle String? @map("meta_title") @db.VarChar(255)
|
||||
metaDescription String? @map("meta_description")
|
||||
|
||||
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([categoryId, locale])
|
||||
@@unique([locale, slug])
|
||||
@@map("category_translations")
|
||||
}
|
||||
|
||||
model CollectionTranslation {
|
||||
collectionId String @map("collection_id") @db.Uuid
|
||||
locale Locale
|
||||
|
||||
name String @db.VarChar(160)
|
||||
slug String @db.VarChar(180)
|
||||
description String?
|
||||
metaTitle String? @map("meta_title") @db.VarChar(255)
|
||||
metaDescription String? @map("meta_description")
|
||||
|
||||
collection Collection @relation(fields: [collectionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([collectionId, locale])
|
||||
@@unique([locale, slug])
|
||||
@@map("collection_translations")
|
||||
}
|
||||
|
||||
/// Per-locale slugs matter here more than anywhere else: `/en/products/
|
||||
/// mens-running-tee` and `/vi/products/ao-chay-bo-nam` are the actual SEO
|
||||
/// surface of the store.
|
||||
model ProductTranslation {
|
||||
productId String @map("product_id") @db.Uuid
|
||||
locale Locale
|
||||
|
||||
name String @db.VarChar(255)
|
||||
slug String @db.VarChar(280)
|
||||
shortDescription String? @map("short_description") @db.VarChar(500)
|
||||
description String?
|
||||
metaTitle String? @map("meta_title") @db.VarChar(255)
|
||||
metaDescription String? @map("meta_description")
|
||||
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([productId, locale])
|
||||
@@unique([locale, slug])
|
||||
@@index([locale, name])
|
||||
@@map("product_translations")
|
||||
}
|
||||
|
||||
model ProductOptionTranslation {
|
||||
optionId String @map("option_id") @db.Uuid
|
||||
locale Locale
|
||||
|
||||
name String @db.VarChar(60)
|
||||
|
||||
option ProductOption @relation(fields: [optionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([optionId, locale])
|
||||
@@map("product_option_translations")
|
||||
}
|
||||
|
||||
/// Colour names are the most visible translated strings on a listing page —
|
||||
/// a grid of swatches labelled "Black" in a Vietnamese store reads as broken.
|
||||
model ProductOptionValueTranslation {
|
||||
optionValueId String @map("option_value_id") @db.Uuid
|
||||
locale Locale
|
||||
|
||||
label String @db.VarChar(80)
|
||||
|
||||
optionValue ProductOptionValue @relation(fields: [optionValueId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([optionValueId, locale])
|
||||
@@map("product_option_value_translations")
|
||||
}
|
||||
|
||||
/// Spec-table rows are user-visible content ("Chất liệu: 92% Polyester"), so
|
||||
/// both the label and the value need translating — a Vietnamese PDP showing an
|
||||
/// English spec table is a half-finished translation.
|
||||
model ProductAttributeTranslation {
|
||||
attributeId String @map("attribute_id") @db.Uuid
|
||||
locale Locale
|
||||
|
||||
label String @db.VarChar(120)
|
||||
value String @db.VarChar(500)
|
||||
|
||||
attribute ProductAttribute @relation(fields: [attributeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([attributeId, locale])
|
||||
@@map("product_attribute_translations")
|
||||
}
|
||||
|
||||
+16
-1
@@ -6,8 +6,10 @@
|
||||
* users: account provisioning belongs to the auth milestone, and baking a
|
||||
* default admin password into a repository is how stores get compromised.
|
||||
*/
|
||||
import { ALL_PERMISSIONS, PERMISSIONS, SYSTEM_ROLES, type Permission } from '@sport/types';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { ALL_PERMISSIONS, PERMISSIONS, SYSTEM_ROLES, type Permission } from '@sport/types';
|
||||
|
||||
import { seedCatalog } from './seed/catalog';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -129,6 +131,19 @@ async function main(): Promise<void> {
|
||||
console.log(
|
||||
`Seed complete: ${permissionIds.size} permissions, ${Object.keys(ROLE_DEFINITIONS).length} roles.`,
|
||||
);
|
||||
|
||||
// Demo catalog: development and staging only. Guarded twice — by NODE_ENV and
|
||||
// by an explicit opt-out — because sample products appearing in a production
|
||||
// storefront is the kind of mistake that reaches customers.
|
||||
const isProduction = process.env['NODE_ENV'] === 'production';
|
||||
const demoDisabled = process.env['SEED_DEMO'] === 'false';
|
||||
|
||||
if (isProduction || demoDisabled) {
|
||||
console.log('Skipping demo catalog seed.');
|
||||
return;
|
||||
}
|
||||
|
||||
await seedCatalog(prisma);
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,650 @@
|
||||
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
import {
|
||||
BRANDS,
|
||||
CATEGORIES,
|
||||
COLLECTIONS,
|
||||
COLOURS,
|
||||
PRODUCTS,
|
||||
type Localised,
|
||||
type ProductSeed,
|
||||
} from './data';
|
||||
import { blurDataUrl, hexToRgb, solidPng } from './png';
|
||||
|
||||
/**
|
||||
* Demo catalog seed. Development and staging only — never production.
|
||||
*
|
||||
* Idempotent by natural key (slug / SKU / path), so re-running updates rather
|
||||
* than duplicating. Deterministic throughout: same input, same stock numbers,
|
||||
* same images. A seed that produces different data on each run makes "is this
|
||||
* a bug or just the seed?" unanswerable.
|
||||
*/
|
||||
|
||||
const IMAGE_WIDTH = 1200;
|
||||
const IMAGE_HEIGHT = 1500;
|
||||
|
||||
type Locale = 'VI' | 'EN';
|
||||
const LOCALES: Locale[] = ['VI', 'EN'];
|
||||
|
||||
function localeValue(value: Localised, locale: Locale): string {
|
||||
return locale === 'VI' ? value.vi : value.en;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic PRNG (mulberry32) seeded from a string. Stock levels vary
|
||||
* across variants — as they do in reality — but never between runs.
|
||||
*/
|
||||
function seededRandom(seed: string): () => number {
|
||||
let h = 1779033703 ^ seed.length;
|
||||
for (let i = 0; i < seed.length; i += 1) {
|
||||
h = Math.imul(h ^ seed.charCodeAt(i), 3432918353);
|
||||
h = (h << 13) | (h >>> 19);
|
||||
}
|
||||
|
||||
let state = h >>> 0;
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(state ^ (state >>> 15), 1 | state);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
export async function seedCatalog(prisma: PrismaClient): Promise<void> {
|
||||
const storage = createStorageClient();
|
||||
|
||||
const brandIds = await seedBrands(prisma);
|
||||
const categoryIds = await seedCategories(prisma);
|
||||
const collectionIds = await seedCollections(prisma);
|
||||
const location = await prisma.inventoryLocation.findUniqueOrThrow({ where: { code: 'MAIN' } });
|
||||
|
||||
for (const product of PRODUCTS) {
|
||||
await seedProduct(prisma, storage, product, {
|
||||
brandId: brandIds.get(product.brand),
|
||||
categoryId: categoryIds.get(product.categoryPath),
|
||||
collectionIds,
|
||||
locationId: location.id,
|
||||
});
|
||||
}
|
||||
|
||||
const dropped = await invalidateCatalogCache();
|
||||
|
||||
console.log(
|
||||
`Catalog: ${BRANDS.length} brands, ${CATEGORIES.length} categories, ` +
|
||||
`${COLLECTIONS.length} collections, ${PRODUCTS.length} products` +
|
||||
`${dropped === null ? '' : `, ${dropped} cache keys dropped`}.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the catalog read cache.
|
||||
*
|
||||
* Without this, re-seeding leaves Redis serving the previous catalog for up to
|
||||
* 15 minutes — which reads as "my changes did nothing" and burns an afternoon.
|
||||
* Failure is non-fatal: an unreachable cache must never fail a seed.
|
||||
*/
|
||||
async function invalidateCatalogCache(): Promise<number | null> {
|
||||
const url = process.env['REDIS_URL'];
|
||||
if (!url) return null;
|
||||
|
||||
const prefix = process.env['REDIS_KEY_PREFIX'] ?? 'sport:';
|
||||
const redis = new Redis(url, { maxRetriesPerRequest: 1, lazyConnect: true });
|
||||
|
||||
try {
|
||||
await redis.connect();
|
||||
|
||||
// SCAN, never KEYS: KEYS blocks the whole server, and this same helper will
|
||||
// eventually run against production during a catalog import.
|
||||
let cursor = '0';
|
||||
let removed = 0;
|
||||
|
||||
do {
|
||||
const [next, keys] = await redis.scan(cursor, 'MATCH', `${prefix}catalog:*`, 'COUNT', 500);
|
||||
cursor = next;
|
||||
if (keys.length > 0) {
|
||||
// Keys from SCAN already carry the prefix; `del` must not re-add it.
|
||||
removed += await redis.del(...keys);
|
||||
}
|
||||
} while (cursor !== '0');
|
||||
|
||||
return removed;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
redis.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createStorageClient(): S3Client {
|
||||
return new S3Client({
|
||||
endpoint: process.env['STORAGE_ENDPOINT'] ?? 'http://localhost:9000',
|
||||
region: process.env['STORAGE_REGION'] ?? 'auto',
|
||||
forcePathStyle: (process.env['STORAGE_FORCE_PATH_STYLE'] ?? 'true') === 'true',
|
||||
credentials: {
|
||||
accessKeyId: process.env['STORAGE_ACCESS_KEY_ID'] ?? 'sportminio',
|
||||
secretAccessKey: process.env['STORAGE_SECRET_ACCESS_KEY'] ?? 'sportminio',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a generated placeholder and records the metadata row.
|
||||
*
|
||||
* Bytes to object storage, key to PostgreSQL — the same split real uploads use
|
||||
* (ADR-0009). The key is derived from the colourway so re-running overwrites
|
||||
* the same object instead of littering the bucket.
|
||||
*/
|
||||
async function upsertMedia(
|
||||
prisma: PrismaClient,
|
||||
storage: S3Client,
|
||||
storageKey: string,
|
||||
hex: string,
|
||||
altText: string,
|
||||
): Promise<string> {
|
||||
const rgb = hexToRgb(hex);
|
||||
const png = solidPng(IMAGE_WIDTH, IMAGE_HEIGHT, rgb);
|
||||
|
||||
await storage.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: process.env['STORAGE_BUCKET'] ?? 'sport-media',
|
||||
Key: storageKey,
|
||||
Body: png,
|
||||
ContentType: 'image/png',
|
||||
CacheControl: 'public, max-age=31536000, immutable',
|
||||
}),
|
||||
);
|
||||
|
||||
const media = await prisma.mediaAsset.upsert({
|
||||
where: { storageKey },
|
||||
update: { altText, sizeBytes: png.length },
|
||||
create: {
|
||||
kind: 'IMAGE',
|
||||
storageKey,
|
||||
mimeType: 'image/png',
|
||||
sizeBytes: png.length,
|
||||
width: IMAGE_WIDTH,
|
||||
height: IMAGE_HEIGHT,
|
||||
blurDataUrl: blurDataUrl(rgb),
|
||||
altText,
|
||||
},
|
||||
});
|
||||
|
||||
return media.id;
|
||||
}
|
||||
|
||||
async function seedBrands(prisma: PrismaClient): Promise<Map<string, string>> {
|
||||
const ids = new Map<string, string>();
|
||||
|
||||
for (const brand of BRANDS) {
|
||||
const row = await prisma.brand.upsert({
|
||||
where: { slug: brand.key },
|
||||
update: { name: brand.name.en, description: brand.description.en },
|
||||
create: { slug: brand.key, name: brand.name.en, description: brand.description.en },
|
||||
});
|
||||
ids.set(brand.key, row.id);
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
await prisma.brandTranslation.upsert({
|
||||
where: { brandId_locale: { brandId: row.id, locale } },
|
||||
update: {
|
||||
name: localeValue(brand.name, locale),
|
||||
slug: localeValue(brand.slug, locale),
|
||||
description: localeValue(brand.description, locale),
|
||||
},
|
||||
create: {
|
||||
brandId: row.id,
|
||||
locale,
|
||||
name: localeValue(brand.name, locale),
|
||||
slug: localeValue(brand.slug, locale),
|
||||
description: localeValue(brand.description, locale),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function seedCategories(prisma: PrismaClient): Promise<Map<string, string>> {
|
||||
const ids = new Map<string, string>();
|
||||
|
||||
// CATEGORIES is ordered parents-first, so a parent id is always known by the
|
||||
// time its children are processed.
|
||||
for (const category of CATEGORIES) {
|
||||
const depth = category.path.split('/').length - 1;
|
||||
const parentId = category.parentPath ? ids.get(category.parentPath) : null;
|
||||
|
||||
const row = await prisma.category.upsert({
|
||||
where: { path: category.path },
|
||||
update: { name: category.name.en, position: category.position, parentId, depth },
|
||||
create: {
|
||||
path: category.path,
|
||||
slug: category.slug.en,
|
||||
name: category.name.en,
|
||||
position: category.position,
|
||||
parentId,
|
||||
depth,
|
||||
},
|
||||
});
|
||||
ids.set(category.path, row.id);
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
await prisma.categoryTranslation.upsert({
|
||||
where: { categoryId_locale: { categoryId: row.id, locale } },
|
||||
update: {
|
||||
name: localeValue(category.name, locale),
|
||||
slug: localeValue(category.slug, locale),
|
||||
},
|
||||
create: {
|
||||
categoryId: row.id,
|
||||
locale,
|
||||
name: localeValue(category.name, locale),
|
||||
slug: localeValue(category.slug, locale),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function seedCollections(prisma: PrismaClient): Promise<Map<string, string>> {
|
||||
const ids = new Map<string, string>();
|
||||
|
||||
for (const collection of COLLECTIONS) {
|
||||
const row = await prisma.collection.upsert({
|
||||
where: { slug: collection.key },
|
||||
update: { name: collection.name.en, position: collection.position },
|
||||
create: {
|
||||
slug: collection.key,
|
||||
name: collection.name.en,
|
||||
description: collection.description.en,
|
||||
position: collection.position,
|
||||
type: 'MANUAL',
|
||||
},
|
||||
});
|
||||
ids.set(collection.key, row.id);
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
await prisma.collectionTranslation.upsert({
|
||||
where: { collectionId_locale: { collectionId: row.id, locale } },
|
||||
update: {
|
||||
name: localeValue(collection.name, locale),
|
||||
slug: localeValue(collection.slug, locale),
|
||||
description: localeValue(collection.description, locale),
|
||||
},
|
||||
create: {
|
||||
collectionId: row.id,
|
||||
locale,
|
||||
name: localeValue(collection.name, locale),
|
||||
slug: localeValue(collection.slug, locale),
|
||||
description: localeValue(collection.description, locale),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function seedProduct(
|
||||
prisma: PrismaClient,
|
||||
storage: S3Client,
|
||||
seed: ProductSeed,
|
||||
refs: {
|
||||
brandId: string | undefined;
|
||||
categoryId: string | undefined;
|
||||
collectionIds: Map<string, string>;
|
||||
locationId: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const product = await prisma.product.upsert({
|
||||
where: { slug: seed.key },
|
||||
update: {
|
||||
name: seed.name.en,
|
||||
status: 'ACTIVE',
|
||||
publishedAt: new Date('2026-01-15T00:00:00Z'),
|
||||
brandId: refs.brandId ?? null,
|
||||
primaryCategoryId: refs.categoryId ?? null,
|
||||
genderTargets: seed.genders,
|
||||
sportTypes: seed.sports,
|
||||
},
|
||||
create: {
|
||||
slug: seed.key,
|
||||
name: seed.name.en,
|
||||
description: seed.description.en,
|
||||
shortDescription: seed.shortDescription.en,
|
||||
status: 'ACTIVE',
|
||||
publishedAt: new Date('2026-01-15T00:00:00Z'),
|
||||
brandId: refs.brandId ?? null,
|
||||
primaryCategoryId: refs.categoryId ?? null,
|
||||
genderTargets: seed.genders,
|
||||
sportTypes: seed.sports,
|
||||
},
|
||||
});
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
// An empty string in the seed means "not translated" — store null so the
|
||||
// API's field-level fallback kicks in rather than rendering a blank.
|
||||
const description = localeValue(seed.description, locale) || null;
|
||||
|
||||
await prisma.productTranslation.upsert({
|
||||
where: { productId_locale: { productId: product.id, locale } },
|
||||
update: {
|
||||
name: localeValue(seed.name, locale),
|
||||
slug: localeValue(seed.slug, locale),
|
||||
shortDescription: localeValue(seed.shortDescription, locale),
|
||||
description,
|
||||
},
|
||||
create: {
|
||||
productId: product.id,
|
||||
locale,
|
||||
name: localeValue(seed.name, locale),
|
||||
slug: localeValue(seed.slug, locale),
|
||||
shortDescription: localeValue(seed.shortDescription, locale),
|
||||
description,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await seedAttributes(prisma, product.id, seed);
|
||||
await prisma.productCollection.deleteMany({ where: { productId: product.id } });
|
||||
for (const key of seed.collections) {
|
||||
const collectionId = refs.collectionIds.get(key);
|
||||
if (collectionId) {
|
||||
await prisma.productCollection.create({ data: { productId: product.id, collectionId } });
|
||||
}
|
||||
}
|
||||
|
||||
const { colourValueIds, sizeValueIds, colourOptionId, sizeOptionId } = await seedOptions(
|
||||
prisma,
|
||||
product.id,
|
||||
seed,
|
||||
);
|
||||
|
||||
await seedImages(prisma, storage, product.id, seed, colourValueIds);
|
||||
await seedVariants(prisma, product.id, seed, {
|
||||
colourOptionId,
|
||||
sizeOptionId,
|
||||
colourValueIds,
|
||||
sizeValueIds,
|
||||
locationId: refs.locationId,
|
||||
});
|
||||
|
||||
await recomputePricing(prisma, product.id);
|
||||
}
|
||||
|
||||
async function seedAttributes(
|
||||
prisma: PrismaClient,
|
||||
productId: string,
|
||||
seed: ProductSeed,
|
||||
): Promise<void> {
|
||||
for (const [index, attribute] of seed.attributes.entries()) {
|
||||
const row = await prisma.productAttribute.upsert({
|
||||
where: { productId_key: { productId, key: attribute.key } },
|
||||
update: { label: attribute.label.en, value: attribute.value.en, position: index },
|
||||
create: {
|
||||
productId,
|
||||
key: attribute.key,
|
||||
label: attribute.label.en,
|
||||
value: attribute.value.en,
|
||||
position: index,
|
||||
},
|
||||
});
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
await prisma.productAttributeTranslation.upsert({
|
||||
where: { attributeId_locale: { attributeId: row.id, locale } },
|
||||
update: {
|
||||
label: localeValue(attribute.label, locale),
|
||||
value: localeValue(attribute.value, locale),
|
||||
},
|
||||
create: {
|
||||
attributeId: row.id,
|
||||
locale,
|
||||
label: localeValue(attribute.label, locale),
|
||||
value: localeValue(attribute.value, locale),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the Colour and Size axes and their values.
|
||||
*
|
||||
* This is the shape the whole catalog model rests on: options define the axes,
|
||||
* values define the allowed points, and the variant table below resolves every
|
||||
* combination into a sellable unit.
|
||||
*/
|
||||
async function seedOptions(prisma: PrismaClient, productId: string, seed: ProductSeed) {
|
||||
const colourOption = await prisma.productOption.upsert({
|
||||
where: { productId_key: { productId, key: 'colour' } },
|
||||
update: { name: 'Colour', position: 0 },
|
||||
create: { productId, key: 'colour', name: 'Colour', position: 0 },
|
||||
});
|
||||
|
||||
const sizeOption = await prisma.productOption.upsert({
|
||||
where: { productId_key: { productId, key: 'size' } },
|
||||
update: { name: 'Size', position: 1 },
|
||||
create: { productId, key: 'size', name: 'Size', position: 1 },
|
||||
});
|
||||
|
||||
for (const [optionId, names] of [
|
||||
[colourOption.id, { vi: 'Màu sắc', en: 'Colour' }],
|
||||
[sizeOption.id, { vi: 'Kích cỡ', en: 'Size' }],
|
||||
] as const) {
|
||||
for (const locale of LOCALES) {
|
||||
await prisma.productOptionTranslation.upsert({
|
||||
where: { optionId_locale: { optionId, locale } },
|
||||
update: { name: localeValue(names, locale) },
|
||||
create: { optionId, locale, name: localeValue(names, locale) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const colourValueIds = new Map<string, string>();
|
||||
for (const [index, colourKey] of seed.colours.entries()) {
|
||||
const colour = COLOURS[colourKey];
|
||||
if (!colour) continue;
|
||||
|
||||
const value = await prisma.productOptionValue.upsert({
|
||||
where: { optionId_value: { optionId: colourOption.id, value: colour.value } },
|
||||
update: { label: colour.label.en, position: index, swatchHex: colour.hex },
|
||||
create: {
|
||||
optionId: colourOption.id,
|
||||
value: colour.value,
|
||||
label: colour.label.en,
|
||||
position: index,
|
||||
swatchHex: colour.hex,
|
||||
},
|
||||
});
|
||||
colourValueIds.set(colour.value, value.id);
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
await prisma.productOptionValueTranslation.upsert({
|
||||
where: { optionValueId_locale: { optionValueId: value.id, locale } },
|
||||
update: { label: localeValue(colour.label, locale) },
|
||||
create: { optionValueId: value.id, locale, label: localeValue(colour.label, locale) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sizes get NO translation rows on purpose: "M" is "M" in both languages.
|
||||
// The API falls back to the base label, which is exactly the intended
|
||||
// behaviour and keeps this from becoming busywork for every new product.
|
||||
const sizeValueIds = new Map<string, string>();
|
||||
for (const [index, size] of seed.sizes.entries()) {
|
||||
const value = await prisma.productOptionValue.upsert({
|
||||
where: { optionId_value: { optionId: sizeOption.id, value: size.toLowerCase() } },
|
||||
update: { label: size, position: index },
|
||||
create: { optionId: sizeOption.id, value: size.toLowerCase(), label: size, position: index },
|
||||
});
|
||||
sizeValueIds.set(size, value.id);
|
||||
}
|
||||
|
||||
return {
|
||||
colourOptionId: colourOption.id,
|
||||
sizeOptionId: sizeOption.id,
|
||||
colourValueIds,
|
||||
sizeValueIds,
|
||||
};
|
||||
}
|
||||
|
||||
async function seedImages(
|
||||
prisma: PrismaClient,
|
||||
storage: S3Client,
|
||||
productId: string,
|
||||
seed: ProductSeed,
|
||||
colourValueIds: Map<string, string>,
|
||||
): Promise<void> {
|
||||
for (const [index, colourKey] of seed.colours.entries()) {
|
||||
const colour = COLOURS[colourKey];
|
||||
const optionValueId = colour ? colourValueIds.get(colour.value) : undefined;
|
||||
if (!colour || !optionValueId) continue;
|
||||
|
||||
// Two images per colourway: the card's default and its hover state.
|
||||
for (const [variantIndex, suffix] of ['a', 'b'].entries()) {
|
||||
const storageKey = `products/seed/${seed.key}-${colour.value}-${suffix}.png`;
|
||||
const mediaId = await upsertMedia(
|
||||
prisma,
|
||||
storage,
|
||||
storageKey,
|
||||
variantIndex === 0 ? colour.hex : shade(colour.hex),
|
||||
`${seed.name.en} — ${colour.label.en}`,
|
||||
);
|
||||
|
||||
await prisma.productImage.upsert({
|
||||
where: { productId_mediaId_optionValueId: { productId, mediaId, optionValueId } },
|
||||
update: { position: index * 2 + variantIndex },
|
||||
create: { productId, mediaId, optionValueId, position: index * 2 + variantIndex },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Slightly darker sibling colour, so the hover image is visibly different. */
|
||||
function shade(hex: string): string {
|
||||
const { r, g, b } = hexToRgb(hex);
|
||||
const darken = (value: number) => Math.max(0, Math.round(value * 0.78));
|
||||
return `#${[darken(r), darken(g), darken(b)].map((v) => v.toString(16).padStart(2, '0')).join('')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The heart of the model: one variant per colour × size, each with its own SKU,
|
||||
* price and stock row.
|
||||
*/
|
||||
async function seedVariants(
|
||||
prisma: PrismaClient,
|
||||
productId: string,
|
||||
seed: ProductSeed,
|
||||
refs: {
|
||||
colourOptionId: string;
|
||||
sizeOptionId: string;
|
||||
colourValueIds: Map<string, string>;
|
||||
sizeValueIds: Map<string, string>;
|
||||
locationId: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const random = seededRandom(seed.key);
|
||||
let position = 0;
|
||||
|
||||
for (const colourKey of seed.colours) {
|
||||
const colour = COLOURS[colourKey];
|
||||
const colourValueId = colour ? refs.colourValueIds.get(colour.value) : undefined;
|
||||
if (!colour || !colourValueId) continue;
|
||||
|
||||
for (const size of seed.sizes) {
|
||||
const sizeValueId = refs.sizeValueIds.get(size);
|
||||
if (!sizeValueId) continue;
|
||||
|
||||
const sku = `${seed.skuPrefix}-${colour.value.toUpperCase()}-${size.toUpperCase()}`;
|
||||
|
||||
const variant = await prisma.productVariant.upsert({
|
||||
where: { sku },
|
||||
update: {
|
||||
title: `${colour.label.en} / ${size}`,
|
||||
priceAmount: seed.price,
|
||||
salePriceAmount: seed.salePrice ?? null,
|
||||
compareAtAmount: seed.compareAt ?? null,
|
||||
position,
|
||||
},
|
||||
create: {
|
||||
productId,
|
||||
sku,
|
||||
title: `${colour.label.en} / ${size}`,
|
||||
currency: 'VND',
|
||||
priceAmount: seed.price,
|
||||
salePriceAmount: seed.salePrice ?? null,
|
||||
compareAtAmount: seed.compareAt ?? null,
|
||||
weightGrams: seed.weightGrams,
|
||||
position,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
|
||||
// Composite PK (variantId, optionId) makes "two colours on one variant"
|
||||
// impossible at the database level.
|
||||
for (const [optionId, optionValueId] of [
|
||||
[refs.colourOptionId, colourValueId],
|
||||
[refs.sizeOptionId, sizeValueId],
|
||||
] as const) {
|
||||
await prisma.productVariantOptionValue.upsert({
|
||||
where: { variantId_optionId: { variantId: variant.id, optionId } },
|
||||
update: { optionValueId },
|
||||
create: { variantId: variant.id, optionId, optionValueId },
|
||||
});
|
||||
}
|
||||
|
||||
// A realistic spread: mostly healthy, some low, a few genuinely sold out
|
||||
// so the out-of-stock UI is exercised without being contrived.
|
||||
const roll = random();
|
||||
const onHand =
|
||||
roll < 0.08 ? 0 : roll < 0.2 ? 1 + Math.floor(random() * 4) : 8 + Math.floor(random() * 40);
|
||||
|
||||
await prisma.stockLevel.upsert({
|
||||
where: { variantId_locationId: { variantId: variant.id, locationId: refs.locationId } },
|
||||
update: { onHand },
|
||||
create: { variantId: variant.id, locationId: refs.locationId, onHand, reserved: 0 },
|
||||
});
|
||||
|
||||
position += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors ProductsRepository.recomputePricing.
|
||||
*
|
||||
* Duplicated here rather than imported because the seed runs through `tsx`
|
||||
* outside the Nest container — instantiating the DI graph to compute four
|
||||
* columns would be the more fragile choice.
|
||||
*/
|
||||
async function recomputePricing(prisma: PrismaClient, productId: string): Promise<void> {
|
||||
const variants = await prisma.productVariant.findMany({
|
||||
where: { productId, status: 'ACTIVE', deletedAt: null },
|
||||
select: {
|
||||
priceAmount: true,
|
||||
salePriceAmount: true,
|
||||
stockLevels: { select: { onHand: true, reserved: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (variants.length === 0) return;
|
||||
|
||||
const effective = variants.map((v) => v.salePriceAmount ?? v.priceAmount);
|
||||
|
||||
await prisma.product.update({
|
||||
where: { id: productId },
|
||||
data: {
|
||||
minPriceAmount: Math.min(...effective),
|
||||
maxPriceAmount: Math.max(...effective),
|
||||
isOnSale: variants.some((v) => v.salePriceAmount !== null),
|
||||
inStock: variants.some((v) => v.stockLevels.some((l) => l.onHand - l.reserved > 0)),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
/**
|
||||
* Demo catalog content.
|
||||
*
|
||||
* Brands are fictional on purpose — seeding a repository with real trademarks
|
||||
* is a liability nobody needs, and it makes it obvious this is sample data.
|
||||
*
|
||||
* Every entity carries both locales so the language switcher has something real
|
||||
* to switch between. A few fields are deliberately left untranslated (see
|
||||
* `sizes`, and the description on one product) to exercise the field-level
|
||||
* fallback in `coalesce`.
|
||||
*/
|
||||
|
||||
export interface Localised {
|
||||
vi: string;
|
||||
en: string;
|
||||
}
|
||||
|
||||
export interface BrandSeed {
|
||||
key: string;
|
||||
name: Localised;
|
||||
slug: Localised;
|
||||
description: Localised;
|
||||
}
|
||||
|
||||
export const BRANDS: BrandSeed[] = [
|
||||
{
|
||||
key: 'velocity',
|
||||
name: { vi: 'Velocity', en: 'Velocity' },
|
||||
slug: { vi: 'velocity', en: 'velocity' },
|
||||
description: {
|
||||
vi: 'Đồ chạy bộ hiệu suất cao, thiết kế cho tốc độ và quãng đường dài.',
|
||||
en: 'High-performance running gear built for speed and distance.',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'ridgeline',
|
||||
name: { vi: 'Ridgeline', en: 'Ridgeline' },
|
||||
slug: { vi: 'ridgeline', en: 'ridgeline' },
|
||||
description: {
|
||||
vi: 'Trang phục tập luyện bền bỉ cho phòng gym và ngoài trời.',
|
||||
en: 'Durable training apparel for the gym and the outdoors.',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'kinetix',
|
||||
name: { vi: 'Kinetix', en: 'Kinetix' },
|
||||
slug: { vi: 'kinetix', en: 'kinetix' },
|
||||
description: {
|
||||
vi: 'Phong cách thể thao đường phố, mặc từ sân tập đến phố.',
|
||||
en: 'Sport-street essentials that move from session to street.',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export interface CategorySeed {
|
||||
path: string;
|
||||
parentPath: string | null;
|
||||
position: number;
|
||||
name: Localised;
|
||||
slug: Localised;
|
||||
}
|
||||
|
||||
export const CATEGORIES: CategorySeed[] = [
|
||||
{
|
||||
path: 'men',
|
||||
parentPath: null,
|
||||
position: 0,
|
||||
name: { vi: 'Nam', en: 'Men' },
|
||||
slug: { vi: 'nam', en: 'men' },
|
||||
},
|
||||
{
|
||||
path: 'women',
|
||||
parentPath: null,
|
||||
position: 1,
|
||||
name: { vi: 'Nữ', en: 'Women' },
|
||||
slug: { vi: 'nu', en: 'women' },
|
||||
},
|
||||
|
||||
{
|
||||
path: 'men/tops',
|
||||
parentPath: 'men',
|
||||
position: 0,
|
||||
name: { vi: 'Áo', en: 'Tops' },
|
||||
slug: { vi: 'ao-nam', en: 'mens-tops' },
|
||||
},
|
||||
{
|
||||
path: 'men/bottoms',
|
||||
parentPath: 'men',
|
||||
position: 1,
|
||||
name: { vi: 'Quần', en: 'Bottoms' },
|
||||
slug: { vi: 'quan-nam', en: 'mens-bottoms' },
|
||||
},
|
||||
{
|
||||
path: 'men/footwear',
|
||||
parentPath: 'men',
|
||||
position: 2,
|
||||
name: { vi: 'Giày', en: 'Footwear' },
|
||||
slug: { vi: 'giay-nam', en: 'mens-footwear' },
|
||||
},
|
||||
|
||||
{
|
||||
path: 'women/tops',
|
||||
parentPath: 'women',
|
||||
position: 0,
|
||||
name: { vi: 'Áo', en: 'Tops' },
|
||||
slug: { vi: 'ao-nu', en: 'womens-tops' },
|
||||
},
|
||||
{
|
||||
path: 'women/bottoms',
|
||||
parentPath: 'women',
|
||||
position: 1,
|
||||
name: { vi: 'Quần', en: 'Bottoms' },
|
||||
slug: { vi: 'quan-nu', en: 'womens-bottoms' },
|
||||
},
|
||||
{
|
||||
path: 'women/footwear',
|
||||
parentPath: 'women',
|
||||
position: 2,
|
||||
name: { vi: 'Giày', en: 'Footwear' },
|
||||
slug: { vi: 'giay-nu', en: 'womens-footwear' },
|
||||
},
|
||||
];
|
||||
|
||||
export interface CollectionSeed {
|
||||
key: string;
|
||||
position: number;
|
||||
name: Localised;
|
||||
slug: Localised;
|
||||
description: Localised;
|
||||
}
|
||||
|
||||
export const COLLECTIONS: CollectionSeed[] = [
|
||||
{
|
||||
key: 'new-arrivals',
|
||||
position: 0,
|
||||
name: { vi: 'Hàng mới về', en: 'New Arrivals' },
|
||||
slug: { vi: 'hang-moi-ve', en: 'new-arrivals' },
|
||||
description: {
|
||||
vi: 'Những mẫu mới nhất vừa cập bến.',
|
||||
en: 'The latest pieces, just landed.',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'summer-drop',
|
||||
position: 1,
|
||||
name: { vi: 'Bộ sưu tập Hè', en: 'Summer Drop' },
|
||||
slug: { vi: 'bo-suu-tap-he', en: 'summer-drop' },
|
||||
description: {
|
||||
vi: 'Chất liệu nhẹ, thoáng khí cho những ngày nóng nhất.',
|
||||
en: 'Lightweight, breathable fabrics for the hottest sessions.',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'sale',
|
||||
position: 2,
|
||||
name: { vi: 'Giảm giá', en: 'Sale' },
|
||||
slug: { vi: 'giam-gia', en: 'sale' },
|
||||
description: {
|
||||
vi: 'Ưu đãi có thời hạn trên các mẫu chọn lọc.',
|
||||
en: 'Limited-time pricing on selected styles.',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export interface ColourSeed {
|
||||
value: string;
|
||||
label: Localised;
|
||||
hex: string;
|
||||
}
|
||||
|
||||
export const COLOURS: Record<string, ColourSeed> = {
|
||||
black: { value: 'black', label: { vi: 'Đen', en: 'Black' }, hex: '#16181d' },
|
||||
white: { value: 'white', label: { vi: 'Trắng', en: 'White' }, hex: '#f2f2ef' },
|
||||
slate: { value: 'slate', label: { vi: 'Xám đá', en: 'Slate' }, hex: '#4a5361' },
|
||||
volt: { value: 'volt', label: { vi: 'Xanh neon', en: 'Volt' }, hex: '#c7f227' },
|
||||
crimson: { value: 'crimson', label: { vi: 'Đỏ thẫm', en: 'Crimson' }, hex: '#b4232a' },
|
||||
navy: { value: 'navy', label: { vi: 'Xanh navy', en: 'Navy' }, hex: '#1d2a45' },
|
||||
sand: { value: 'sand', label: { vi: 'Be cát', en: 'Sand' }, hex: '#d8c9b0' },
|
||||
teal: { value: 'teal', label: { vi: 'Xanh ngọc', en: 'Teal' }, hex: '#1f6f6b' },
|
||||
};
|
||||
|
||||
export type Gender = 'MEN' | 'WOMEN' | 'KIDS' | 'UNISEX';
|
||||
export type Sport = 'RUNNING' | 'FOOTBALL' | 'TRAINING' | 'GYM' | 'BADMINTON' | 'LIFESTYLE';
|
||||
|
||||
export interface ProductSeed {
|
||||
key: string;
|
||||
skuPrefix: string;
|
||||
brand: string;
|
||||
categoryPath: string;
|
||||
genders: Gender[];
|
||||
sports: Sport[];
|
||||
collections: string[];
|
||||
colours: string[];
|
||||
sizes: string[];
|
||||
/** VND, integer minor units (VND has no minor unit). */
|
||||
price: number;
|
||||
/** Present on discounted products only. */
|
||||
salePrice?: number;
|
||||
compareAt?: number;
|
||||
weightGrams: number;
|
||||
name: Localised;
|
||||
slug: Localised;
|
||||
shortDescription: Localised;
|
||||
description: Localised;
|
||||
attributes: { key: string; label: Localised; value: Localised }[];
|
||||
}
|
||||
|
||||
const APPAREL_SIZES = ['XS', 'S', 'M', 'L', 'XL'];
|
||||
const SHOE_SIZES = ['39', '40', '41', '42', '43'];
|
||||
|
||||
export const PRODUCTS: ProductSeed[] = [
|
||||
{
|
||||
key: 'aero-run-tee',
|
||||
skuPrefix: 'VEL-ART',
|
||||
brand: 'velocity',
|
||||
categoryPath: 'men/tops',
|
||||
genders: ['MEN'],
|
||||
sports: ['RUNNING', 'TRAINING'],
|
||||
collections: ['new-arrivals', 'summer-drop'],
|
||||
colours: ['black', 'white', 'volt'],
|
||||
sizes: APPAREL_SIZES,
|
||||
price: 690000,
|
||||
weightGrams: 135,
|
||||
name: { vi: 'Áo Chạy Bộ Aero', en: 'Aero Run Tee' },
|
||||
slug: { vi: 'ao-chay-bo-aero', en: 'aero-run-tee' },
|
||||
shortDescription: {
|
||||
vi: 'Áo chạy siêu nhẹ với công nghệ thoát ẩm.',
|
||||
en: 'Ultralight running tee with moisture-wicking knit.',
|
||||
},
|
||||
description: {
|
||||
vi: 'Được thiết kế cho những buổi chạy dài dưới nắng. Chất liệu dệt kim siêu nhẹ đưa mồ hôi ra bề mặt và khô nhanh, trong khi đường may phẳng loại bỏ ma sát ở vai và sườn.',
|
||||
en: 'Built for long runs in the heat. An ultralight knit pulls sweat to the surface and dries fast, while flatlock seams remove the friction points at the shoulder and ribs.',
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: 'material',
|
||||
label: { vi: 'Chất liệu', en: 'Material' },
|
||||
value: { vi: '92% Polyester, 8% Elastane', en: '92% polyester, 8% elastane' },
|
||||
},
|
||||
{
|
||||
key: 'fit',
|
||||
label: { vi: 'Kiểu dáng', en: 'Fit' },
|
||||
value: { vi: 'Ôm vừa', en: 'Athletic fit' },
|
||||
},
|
||||
{
|
||||
key: 'care',
|
||||
label: { vi: 'Bảo quản', en: 'Care' },
|
||||
value: { vi: 'Giặt máy lạnh, không sấy', en: 'Machine wash cold, do not tumble dry' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'tempo-split-short',
|
||||
skuPrefix: 'VEL-TSS',
|
||||
brand: 'velocity',
|
||||
categoryPath: 'men/bottoms',
|
||||
genders: ['MEN'],
|
||||
sports: ['RUNNING'],
|
||||
collections: ['summer-drop'],
|
||||
colours: ['black', 'navy'],
|
||||
sizes: APPAREL_SIZES,
|
||||
price: 850000,
|
||||
salePrice: 595000,
|
||||
compareAt: 850000,
|
||||
weightGrams: 110,
|
||||
name: { vi: 'Quần Short Chạy Tempo', en: 'Tempo Split Short' },
|
||||
slug: { vi: 'quan-short-chay-tempo', en: 'tempo-split-short' },
|
||||
shortDescription: {
|
||||
vi: 'Short xẻ tà 4 inch với lớp lót liền.',
|
||||
en: 'Four-inch split-hem short with a bonded liner.',
|
||||
},
|
||||
description: {
|
||||
vi: 'Short xẻ tà cho sải chân tự do tuyệt đối. Lớp lót liền giữ form, túi sau có khoá kéo đủ chỗ cho chìa khoá và thẻ.',
|
||||
en: 'A split hem gives the stride complete freedom. The bonded liner holds its shape, and the zipped rear pocket takes a key and a card without bouncing.',
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: 'material',
|
||||
label: { vi: 'Chất liệu', en: 'Material' },
|
||||
value: { vi: '100% Polyester tái chế', en: '100% recycled polyester' },
|
||||
},
|
||||
{
|
||||
key: 'inseam',
|
||||
label: { vi: 'Chiều dài ống', en: 'Inseam' },
|
||||
value: { vi: '10 cm', en: '10 cm' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'forge-training-hoodie',
|
||||
skuPrefix: 'RDG-FTH',
|
||||
brand: 'ridgeline',
|
||||
categoryPath: 'men/tops',
|
||||
genders: ['MEN', 'UNISEX'],
|
||||
sports: ['GYM', 'TRAINING'],
|
||||
collections: ['new-arrivals'],
|
||||
colours: ['slate', 'black', 'sand'],
|
||||
sizes: APPAREL_SIZES,
|
||||
price: 1490000,
|
||||
weightGrams: 520,
|
||||
name: { vi: 'Áo Hoodie Tập Luyện Forge', en: 'Forge Training Hoodie' },
|
||||
slug: { vi: 'ao-hoodie-tap-luyen-forge', en: 'forge-training-hoodie' },
|
||||
shortDescription: {
|
||||
vi: 'Hoodie nỉ dày cho buổi khởi động và nghỉ giữa hiệp.',
|
||||
en: 'Heavyweight fleece hoodie for warm-ups and rest days.',
|
||||
},
|
||||
description: {
|
||||
vi: 'Nỉ bông chải dày 380gsm giữ nhiệt qua phần khởi động, cổ tay bo sườn giữ ống tay đúng chỗ khi kéo tạ.',
|
||||
en: 'A brushed 380gsm fleece holds heat through the warm-up, and ribbed cuffs keep the sleeves where you put them under the bar.',
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: 'material',
|
||||
label: { vi: 'Chất liệu', en: 'Material' },
|
||||
value: { vi: '80% Cotton, 20% Polyester', en: '80% cotton, 20% polyester' },
|
||||
},
|
||||
{
|
||||
key: 'weight',
|
||||
label: { vi: 'Định lượng', en: 'Fabric weight' },
|
||||
value: { vi: '380 gsm', en: '380 gsm' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'grid-training-short',
|
||||
skuPrefix: 'RDG-GTS',
|
||||
brand: 'ridgeline',
|
||||
categoryPath: 'men/bottoms',
|
||||
genders: ['MEN'],
|
||||
sports: ['GYM', 'TRAINING'],
|
||||
collections: ['sale'],
|
||||
colours: ['black', 'slate'],
|
||||
sizes: APPAREL_SIZES,
|
||||
price: 790000,
|
||||
salePrice: 550000,
|
||||
compareAt: 790000,
|
||||
weightGrams: 190,
|
||||
name: { vi: 'Quần Short Tập Grid', en: 'Grid Training Short' },
|
||||
slug: { vi: 'quan-short-tap-grid', en: 'grid-training-short' },
|
||||
shortDescription: {
|
||||
vi: 'Short co giãn 4 chiều cho squat và lunge.',
|
||||
en: 'Four-way stretch short for squats and lunges.',
|
||||
},
|
||||
description: {
|
||||
vi: 'Vải co giãn bốn chiều với gusset ở đáy quần cho biên độ squat sâu mà không bị kéo căng.',
|
||||
en: 'Four-way stretch woven with a gusseted crotch, so a deep squat never pulls the waistband down with it.',
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: 'material',
|
||||
label: { vi: 'Chất liệu', en: 'Material' },
|
||||
value: { vi: '88% Nylon, 12% Elastane', en: '88% nylon, 12% elastane' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'flux-seamless-leggings',
|
||||
skuPrefix: 'RDG-FSL',
|
||||
brand: 'ridgeline',
|
||||
categoryPath: 'women/bottoms',
|
||||
genders: ['WOMEN'],
|
||||
sports: ['GYM', 'TRAINING'],
|
||||
collections: ['new-arrivals', 'summer-drop'],
|
||||
colours: ['black', 'teal', 'sand'],
|
||||
sizes: APPAREL_SIZES,
|
||||
price: 1290000,
|
||||
weightGrams: 240,
|
||||
name: { vi: 'Quần Legging Liền Mạch Flux', en: 'Flux Seamless Leggings' },
|
||||
slug: { vi: 'quan-legging-lien-mach-flux', en: 'flux-seamless-leggings' },
|
||||
shortDescription: {
|
||||
vi: 'Legging dệt liền mạch, cạp cao, không lộ.',
|
||||
en: 'Seamless high-waist leggings with squat-proof knit.',
|
||||
},
|
||||
description: {
|
||||
vi: 'Dệt liền mạch loại bỏ đường may ở đùi trong — nơi gây khó chịu nhất. Cạp cao ôm sát và giữ nguyên vị trí suốt buổi tập.',
|
||||
en: 'A seamless knit removes the inner-thigh seam entirely, which is where irritation always starts. The high waistband stays put for the whole session.',
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: 'material',
|
||||
label: { vi: 'Chất liệu', en: 'Material' },
|
||||
value: { vi: '75% Nylon, 25% Elastane', en: '75% nylon, 25% elastane' },
|
||||
},
|
||||
{
|
||||
key: 'rise',
|
||||
label: { vi: 'Cạp quần', en: 'Rise' },
|
||||
value: { vi: 'Cạp cao', en: 'High rise' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'aria-training-bra',
|
||||
skuPrefix: 'RDG-ATB',
|
||||
brand: 'ridgeline',
|
||||
categoryPath: 'women/tops',
|
||||
genders: ['WOMEN'],
|
||||
sports: ['GYM', 'TRAINING', 'RUNNING'],
|
||||
collections: ['new-arrivals'],
|
||||
colours: ['black', 'white', 'crimson'],
|
||||
sizes: ['XS', 'S', 'M', 'L'],
|
||||
price: 690000,
|
||||
weightGrams: 95,
|
||||
name: { vi: 'Áo Bra Tập Luyện Aria', en: 'Aria Training Bra' },
|
||||
slug: { vi: 'ao-bra-tap-luyen-aria', en: 'aria-training-bra' },
|
||||
shortDescription: {
|
||||
vi: 'Nâng đỡ mức trung bình, lưng chữ Y thoáng khí.',
|
||||
en: 'Medium support with a breathable Y-back.',
|
||||
},
|
||||
description: {
|
||||
vi: 'Nâng đỡ mức trung bình cho tập tạ và chạy nhẹ. Lưng chữ Y mở rộng vai và giữ dây không trượt.',
|
||||
en: 'Medium support for lifting and easy runs. The Y-back opens the shoulders and keeps the straps from wandering.',
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: 'support',
|
||||
label: { vi: 'Mức nâng đỡ', en: 'Support' },
|
||||
value: { vi: 'Trung bình', en: 'Medium' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'stride-runner',
|
||||
skuPrefix: 'VEL-STR',
|
||||
brand: 'velocity',
|
||||
categoryPath: 'men/footwear',
|
||||
genders: ['MEN', 'UNISEX'],
|
||||
sports: ['RUNNING'],
|
||||
collections: ['new-arrivals'],
|
||||
colours: ['white', 'volt', 'navy'],
|
||||
sizes: SHOE_SIZES,
|
||||
price: 3290000,
|
||||
weightGrams: 245,
|
||||
name: { vi: 'Giày Chạy Stride', en: 'Stride Runner' },
|
||||
slug: { vi: 'giay-chay-stride', en: 'stride-runner' },
|
||||
shortDescription: {
|
||||
vi: 'Giày chạy hằng ngày với đế đàn hồi cao.',
|
||||
en: 'Daily trainer with a high-rebound midsole.',
|
||||
},
|
||||
description: {
|
||||
vi: 'Đế giữa siêu nhẹ trả lại năng lượng qua từng bước, phù hợp cho cả buổi chạy phục hồi lẫn chạy dài cuối tuần.',
|
||||
en: 'A supercritical foam midsole returns energy step after step — equally at home on a recovery jog and a weekend long run.',
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: 'drop',
|
||||
label: { vi: 'Độ chênh gót', en: 'Heel drop' },
|
||||
value: { vi: '8 mm', en: '8 mm' },
|
||||
},
|
||||
{
|
||||
key: 'use',
|
||||
label: { vi: 'Mục đích', en: 'Best for' },
|
||||
value: { vi: 'Chạy đường nhựa hằng ngày', en: 'Daily road running' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'pivot-court-shoe',
|
||||
skuPrefix: 'KNX-PCS',
|
||||
brand: 'kinetix',
|
||||
categoryPath: 'women/footwear',
|
||||
genders: ['WOMEN'],
|
||||
sports: ['BADMINTON'],
|
||||
collections: ['new-arrivals'],
|
||||
colours: ['white', 'teal'],
|
||||
sizes: ['36', '37', '38', '39'],
|
||||
price: 2490000,
|
||||
salePrice: 1990000,
|
||||
compareAt: 2490000,
|
||||
weightGrams: 280,
|
||||
name: { vi: 'Giày Cầu Lông Pivot', en: 'Pivot Court Shoe' },
|
||||
slug: { vi: 'giay-cau-long-pivot', en: 'pivot-court-shoe' },
|
||||
shortDescription: {
|
||||
vi: 'Đế bám sân, hỗ trợ đổi hướng nhanh.',
|
||||
en: 'Grippy non-marking outsole for fast direction changes.',
|
||||
},
|
||||
description: {
|
||||
vi: 'Đế ngoài không để lại vệt, bám chắc khi bước lunge về trước và ổn định phần giữa bàn chân khi đổi hướng đột ngột.',
|
||||
en: 'A non-marking outsole bites on the forward lunge, and a reinforced midfoot keeps the shoe under you through sharp changes of direction.',
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: 'outsole',
|
||||
label: { vi: 'Đế ngoài', en: 'Outsole' },
|
||||
value: { vi: 'Cao su không vệt', en: 'Non-marking rubber' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'match-football-jersey',
|
||||
skuPrefix: 'KNX-MFJ',
|
||||
brand: 'kinetix',
|
||||
categoryPath: 'men/tops',
|
||||
genders: ['MEN', 'UNISEX'],
|
||||
sports: ['FOOTBALL'],
|
||||
collections: ['summer-drop'],
|
||||
colours: ['crimson', 'navy', 'white'],
|
||||
sizes: APPAREL_SIZES,
|
||||
price: 950000,
|
||||
weightGrams: 155,
|
||||
name: { vi: 'Áo Đấu Bóng Đá Match', en: 'Match Football Jersey' },
|
||||
slug: { vi: 'ao-dau-bong-da-match', en: 'match-football-jersey' },
|
||||
shortDescription: {
|
||||
vi: 'Áo đấu thoáng khí với lưới thoát nhiệt.',
|
||||
en: 'Breathable match jersey with ventilated mesh.',
|
||||
},
|
||||
description: {
|
||||
vi: 'Các tấm lưới đặt đúng vị trí toả nhiệt ở lưng và nách, phần thân dài hơn giữ áo trong quần suốt trận.',
|
||||
en: 'Mesh panels sit exactly where heat builds — the back and the underarms — and a longer body keeps the shirt tucked for the full ninety.',
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: 'material',
|
||||
label: { vi: 'Chất liệu', en: 'Material' },
|
||||
value: { vi: '100% Polyester tái chế', en: '100% recycled polyester' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'district-track-jacket',
|
||||
skuPrefix: 'KNX-DTJ',
|
||||
brand: 'kinetix',
|
||||
categoryPath: 'men/tops',
|
||||
genders: ['UNISEX'],
|
||||
sports: ['LIFESTYLE'],
|
||||
collections: ['new-arrivals'],
|
||||
colours: ['black', 'sand', 'teal'],
|
||||
sizes: APPAREL_SIZES,
|
||||
price: 1690000,
|
||||
weightGrams: 420,
|
||||
name: { vi: 'Áo Khoác Track District', en: 'District Track Jacket' },
|
||||
slug: { vi: 'ao-khoac-track-district', en: 'district-track-jacket' },
|
||||
shortDescription: {
|
||||
vi: 'Áo khoác track phom retro cho phố xá.',
|
||||
en: 'Retro-cut track jacket for off-duty days.',
|
||||
},
|
||||
// Vietnamese description intentionally omitted below to exercise the
|
||||
// field-level translation fallback — the VI page shows this English copy.
|
||||
description: {
|
||||
vi: '',
|
||||
en: 'A retro track cut with a full-length zip and side stripes, in a heavier tricot that hangs properly instead of clinging.',
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: 'fit',
|
||||
label: { vi: 'Kiểu dáng', en: 'Fit' },
|
||||
value: { vi: 'Rộng vừa', en: 'Relaxed fit' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'core-lifting-tank',
|
||||
skuPrefix: 'RDG-CLT',
|
||||
brand: 'ridgeline',
|
||||
categoryPath: 'men/tops',
|
||||
genders: ['MEN'],
|
||||
sports: ['GYM'],
|
||||
collections: ['sale', 'summer-drop'],
|
||||
colours: ['black', 'slate', 'volt'],
|
||||
sizes: APPAREL_SIZES,
|
||||
price: 490000,
|
||||
salePrice: 340000,
|
||||
compareAt: 490000,
|
||||
weightGrams: 120,
|
||||
name: { vi: 'Áo Ba Lỗ Tập Tạ Core', en: 'Core Lifting Tank' },
|
||||
slug: { vi: 'ao-ba-lo-tap-ta-core', en: 'core-lifting-tank' },
|
||||
shortDescription: {
|
||||
vi: 'Áo ba lỗ nách rộng, thoáng cho ngày tập tạ.',
|
||||
en: 'Drop-armhole tank that stays out of the way under the bar.',
|
||||
},
|
||||
description: {
|
||||
vi: 'Nách khoét sâu cho vai chuyển động tự do khi đẩy qua đầu, thân áo đủ dài để không bị kéo lên khi deadlift.',
|
||||
en: 'A dropped armhole lets the shoulder travel freely overhead, and the body runs long enough that it does not ride up mid-deadlift.',
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: 'material',
|
||||
label: { vi: 'Chất liệu', en: 'Material' },
|
||||
value: { vi: '60% Cotton, 40% Polyester', en: '60% cotton, 40% polyester' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'lumen-running-jacket',
|
||||
skuPrefix: 'VEL-LRJ',
|
||||
brand: 'velocity',
|
||||
categoryPath: 'women/tops',
|
||||
genders: ['WOMEN'],
|
||||
sports: ['RUNNING', 'LIFESTYLE'],
|
||||
collections: ['new-arrivals'],
|
||||
colours: ['volt', 'black'],
|
||||
sizes: APPAREL_SIZES,
|
||||
price: 1890000,
|
||||
weightGrams: 180,
|
||||
name: { vi: 'Áo Khoác Chạy Lumen', en: 'Lumen Running Jacket' },
|
||||
slug: { vi: 'ao-khoac-chay-lumen', en: 'lumen-running-jacket' },
|
||||
shortDescription: {
|
||||
vi: 'Áo khoác gió nhẹ, phản quang 360°.',
|
||||
en: 'Featherweight windbreaker with 360° reflectivity.',
|
||||
},
|
||||
description: {
|
||||
vi: 'Chắn gió mà không giữ nhiệt, chi tiết phản quang quanh thân giúp bạn được nhìn thấy từ mọi hướng khi chạy sớm hoặc tối.',
|
||||
en: 'Cuts the wind without trapping heat, and reflective detailing wraps the whole body so early and late runs stay visible from every angle.',
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: 'packable',
|
||||
label: { vi: 'Gấp gọn', en: 'Packable' },
|
||||
value: { vi: 'Gấp vào túi ngực', en: 'Packs into its chest pocket' },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,114 @@
|
||||
import { deflateSync } from 'node:zlib';
|
||||
|
||||
/**
|
||||
* A dependency-free PNG encoder for seed placeholder imagery.
|
||||
*
|
||||
* Why not just point MediaAsset rows at URLs that do not exist: the seed should
|
||||
* exercise the real media path — bytes in object storage, only a key in
|
||||
* PostgreSQL (ADR-0009). Broken images would hide a misconfigured bucket, a
|
||||
* wrong public URL or a missing Next.js `remotePatterns` entry until much later.
|
||||
*
|
||||
* Why not `sharp`: a native dependency for solid-colour rectangles is not a
|
||||
* trade worth making. A solid PNG is ~60 lines of well-specified format.
|
||||
*/
|
||||
|
||||
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
|
||||
const CRC_TABLE = (() => {
|
||||
const table = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n += 1) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k += 1) {
|
||||
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
}
|
||||
table[n] = c >>> 0;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
function crc32(buffer: Buffer): number {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of buffer) {
|
||||
crc = (CRC_TABLE[(crc ^ byte) & 0xff] ?? 0) ^ (crc >>> 8);
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function chunk(type: string, data: Buffer): Buffer {
|
||||
const length = Buffer.alloc(4);
|
||||
length.writeUInt32BE(data.length, 0);
|
||||
|
||||
const typeAndData = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
||||
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(typeAndData), 0);
|
||||
|
||||
return Buffer.concat([length, typeAndData, crc]);
|
||||
}
|
||||
|
||||
export interface Rgb {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
/** `#1a2b3c` → { r, g, b }. Falls back to mid-grey on anything unparseable. */
|
||||
export function hexToRgb(hex: string): Rgb {
|
||||
const match = /^#?([\da-f]{6})$/i.exec(hex.trim());
|
||||
if (!match?.[1]) return { r: 128, g: 128, b: 128 };
|
||||
|
||||
const value = Number.parseInt(match[1], 16);
|
||||
return { r: (value >> 16) & 0xff, g: (value >> 8) & 0xff, b: value & 0xff };
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a solid PNG with a subtle vertical gradient, which reads as a studio
|
||||
* backdrop rather than a flat colour swatch in a product grid.
|
||||
*/
|
||||
export function solidPng(width: number, height: number, base: Rgb): Buffer {
|
||||
const bytesPerRow = width * 3 + 1; // +1 for the per-row filter byte
|
||||
const raw = Buffer.alloc(bytesPerRow * height);
|
||||
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
const rowStart = y * bytesPerRow;
|
||||
raw[rowStart] = 0; // filter type 0 (None)
|
||||
|
||||
// -8%..+8% luminance across the height.
|
||||
const factor = 0.92 + (y / Math.max(height - 1, 1)) * 0.16;
|
||||
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const offset = rowStart + 1 + x * 3;
|
||||
raw[offset] = clampByte(base.r * factor);
|
||||
raw[offset + 1] = clampByte(base.g * factor);
|
||||
raw[offset + 2] = clampByte(base.b * factor);
|
||||
}
|
||||
}
|
||||
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr.writeUInt8(8, 8); // bit depth
|
||||
ihdr.writeUInt8(2, 9); // colour type 2 = truecolour RGB
|
||||
ihdr.writeUInt8(0, 10); // compression
|
||||
ihdr.writeUInt8(0, 11); // filter
|
||||
ihdr.writeUInt8(0, 12); // interlace
|
||||
|
||||
return Buffer.concat([
|
||||
PNG_SIGNATURE,
|
||||
chunk('IHDR', ihdr),
|
||||
chunk('IDAT', deflateSync(raw, { level: 9 })),
|
||||
chunk('IEND', Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
function clampByte(value: number): number {
|
||||
return Math.max(0, Math.min(255, Math.round(value)));
|
||||
}
|
||||
|
||||
/**
|
||||
* A 1×1 PNG of the same colour, base64-encoded, used as the LQIP blur
|
||||
* placeholder so grids never flash empty while images load.
|
||||
*/
|
||||
export function blurDataUrl(base: Rgb): string {
|
||||
return `data:image/png;base64,${solidPng(1, 1, base).toString('base64')}`;
|
||||
}
|
||||
Reference in New Issue
Block a user