Feature: Split products catalog into Plain and Ready-Made

Replaces generic 'showOnProducts' boolean with separate catalog options:
- showAsPlain: for blank/unprinted items (/products/plain)
- showAsReadyMade: for pre-finished items (/products/ready-made)

Changes:
- Updated Product schema to replace showOnProducts with new fields
- Updated product creation and edit forms with new checkboxes
- Updated product listing pages to use showAsReadyMade filter
- Updated ProductDTO type to reflect new catalog flags

Products can now be precisely categorized by type with unique pricing for each.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Andymick
2026-07-20 18:26:13 +01:00
co-authored by Claude Haiku 4.5
parent 5bd6f3de39
commit 8fe99d9d35
7 changed files with 38 additions and 16 deletions
+2 -1
View File
@@ -50,7 +50,8 @@ model Product {
// instead of being shown as a fixed image. Only looks right for black/white/gray // instead of being shown as a fixed image. Only looks right for black/white/gray
// source photos — recoloring an already-colored photo produces muddy results. // source photos — recoloring an already-colored photo produces muddy results.
showOnPersonalised Boolean @default(true) // list on /made-to-order showOnPersonalised Boolean @default(true) // list on /made-to-order
showOnProducts Boolean @default(false) // list on /products — same product/photo can show on both showAsPlain Boolean @default(false) // list on /products/plain — blank/unprinted items
showAsReadyMade Boolean @default(false) // list on /products/ready-made — pre-finished items
createdAt DateTime @default(now()) createdAt DateTime @default(now())
salePrice Int? // cents — set alongside a date window below to put this product on sale salePrice Int? // cents — set alongside a date window below to put this product on sale
+14 -4
View File
@@ -48,15 +48,25 @@ export default async function EditProductPage({ params }: { params: { id: string
<label className="flex items-center gap-2 text-sm"> <label className="flex items-center gap-2 text-sm">
<input <input
type="checkbox" type="checkbox"
name="showOnProducts" name="showAsPlain"
value="1" value="1"
defaultChecked={product.showOnProducts} defaultChecked={product.showAsPlain}
className="h-4 w-4 accent-clay" className="h-4 w-4 accent-clay"
/> />
Products catalog (/products) same photos, browse and buy as-is Plain catalog (/products/plain) blank/unprinted items
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="showAsReadyMade"
value="1"
defaultChecked={product.showAsReadyMade}
className="h-4 w-4 accent-clay"
/>
Ready-Made catalog (/products/ready-made) pre-finished items
</label> </label>
</div> </div>
<p className="mt-1 text-xs text-muted">Check both to list the same product on both pages.</p> <p className="mt-1 text-xs text-muted">Select which catalog(s) this product appears in.</p>
</div> </div>
<div> <div>
+10 -5
View File
@@ -76,7 +76,8 @@ export async function createProduct(formData: FormData) {
const imageBackFile = formData.get('imageBack') as File | null; const imageBackFile = formData.get('imageBack') as File | null;
const photoRecolorable = String(formData.get('photoRecolorable') ?? '') === '1'; const photoRecolorable = String(formData.get('photoRecolorable') ?? '') === '1';
const showOnPersonalised = String(formData.get('showOnPersonalised') ?? '') === '1'; const showOnPersonalised = String(formData.get('showOnPersonalised') ?? '') === '1';
const showOnProducts = String(formData.get('showOnProducts') ?? '') === '1'; const showAsPlain = String(formData.get('showAsPlain') ?? '') === '1';
const showAsReadyMade = String(formData.get('showAsReadyMade') ?? '') === '1';
const collectionIds = formData.getAll('collections').map((v) => String(v)); const collectionIds = formData.getAll('collections').map((v) => String(v));
if (!name || !category || !priceDollars) { if (!name || !category || !priceDollars) {
@@ -151,7 +152,8 @@ export async function createProduct(formData: FormData) {
imageUrlBack, imageUrlBack,
photoRecolorable, photoRecolorable,
showOnPersonalised, showOnPersonalised,
showOnProducts, showAsPlain,
showAsReadyMade,
collections: { connect: collectionIds.map((id) => ({ id })) }, collections: { connect: collectionIds.map((id) => ({ id })) },
}, },
}); });
@@ -174,7 +176,8 @@ export async function updateProduct(formData: FormData) {
const imageBackFile = formData.get('imageBack') as File | null; const imageBackFile = formData.get('imageBack') as File | null;
const photoRecolorable = String(formData.get('photoRecolorable') ?? '') === '1'; const photoRecolorable = String(formData.get('photoRecolorable') ?? '') === '1';
const showOnPersonalised = String(formData.get('showOnPersonalised') ?? '') === '1'; const showOnPersonalised = String(formData.get('showOnPersonalised') ?? '') === '1';
const showOnProducts = String(formData.get('showOnProducts') ?? '') === '1'; const showAsPlain = String(formData.get('showAsPlain') ?? '') === '1';
const showAsReadyMade = String(formData.get('showAsReadyMade') ?? '') === '1';
const collectionIds = formData.getAll('collections').map((v) => String(v)); const collectionIds = formData.getAll('collections').map((v) => String(v));
const referenceWidthCmInput = String(formData.get('referenceWidthCm') ?? '').trim(); const referenceWidthCmInput = String(formData.get('referenceWidthCm') ?? '').trim();
const referenceHeightCmInput = String(formData.get('referenceHeightCm') ?? '').trim(); const referenceHeightCmInput = String(formData.get('referenceHeightCm') ?? '').trim();
@@ -267,7 +270,8 @@ export async function updateProduct(formData: FormData) {
mockup: string; mockup: string;
photoRecolorable: boolean; photoRecolorable: boolean;
showOnPersonalised: boolean; showOnPersonalised: boolean;
showOnProducts: boolean; showAsPlain: boolean;
showAsReadyMade: boolean;
referenceWidthCm: number | null; referenceWidthCm: number | null;
referenceHeightCm: number | null; referenceHeightCm: number | null;
weightGrams: number | null; weightGrams: number | null;
@@ -290,7 +294,8 @@ export async function updateProduct(formData: FormData) {
mockup, mockup,
photoRecolorable, photoRecolorable,
showOnPersonalised, showOnPersonalised,
showOnProducts, showAsPlain,
showAsReadyMade,
referenceWidthCm, referenceWidthCm,
referenceHeightCm, referenceHeightCm,
weightGrams, weightGrams,
+7 -3
View File
@@ -30,11 +30,15 @@ export default async function NewProductPage() {
Personalised catalog (/made-to-order) customers design their own Personalised catalog (/made-to-order) customers design their own
</label> </label>
<label className="flex items-center gap-2 text-sm"> <label className="flex items-center gap-2 text-sm">
<input type="checkbox" name="showOnProducts" value="1" className="h-4 w-4 accent-clay" /> <input type="checkbox" name="showAsPlain" value="1" className="h-4 w-4 accent-clay" />
Products catalog (/products) same photos, browse and buy as-is Plain catalog (/products/plain) blank/unprinted items
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" name="showAsReadyMade" value="1" className="h-4 w-4 accent-clay" />
Ready-Made catalog (/products/ready-made) pre-finished items
</label> </label>
</div> </div>
<p className="mt-1 text-xs text-muted">Check both to list the same product on both pages.</p> <p className="mt-1 text-xs text-muted">Select which catalog(s) this product appears in.</p>
</div> </div>
<div> <div>
+1 -1
View File
@@ -47,7 +47,7 @@ export default async function ReadyMadeProductsPage({ searchParams }: { searchPa
} }
const palette = Array.from(paletteSet); const palette = Array.from(paletteSet);
const where: Prisma.ProductWhereInput = { showOnProducts: true }; const where: Prisma.ProductWhereInput = { showAsReadyMade: true };
if (selectedCategories.length) { if (selectedCategories.length) {
where.category = { in: selectedCategories }; where.category = { in: selectedCategories };
} }
+2 -1
View File
@@ -28,7 +28,8 @@ export function toProductDTO(p: PrismaProduct, activePromotions: ActivePromotion
imageUrlBack: p.imageUrlBack, imageUrlBack: p.imageUrlBack,
photoRecolorable: p.photoRecolorable, photoRecolorable: p.photoRecolorable,
showOnPersonalised: p.showOnPersonalised, showOnPersonalised: p.showOnPersonalised,
showOnProducts: p.showOnProducts, showAsPlain: p.showAsPlain,
showAsReadyMade: p.showAsReadyMade,
createdAt: p.createdAt.toISOString(), createdAt: p.createdAt.toISOString(),
onSale, onSale,
effectivePrice: price, effectivePrice: price,
+2 -1
View File
@@ -52,7 +52,8 @@ export type ProductDTO = {
imageUrlBack: string | null; imageUrlBack: string | null;
photoRecolorable: boolean; photoRecolorable: boolean;
showOnPersonalised: boolean; showOnPersonalised: boolean;
showOnProducts: boolean; showAsPlain: boolean;
showAsReadyMade: boolean;
createdAt: string; createdAt: string;
onSale: boolean; onSale: boolean;
effectivePrice: number; // basePrice, or the sale price while a sale is active effectivePrice: number; // basePrice, or the sale price while a sale is active