Compare commits

...
10 Commits
Author SHA1 Message Date
AndymickandClaude Haiku 4.5 e23f201d3d Fabric.js design canvas implementation and supporting infrastructure
Design Canvas (Primary)
- FabricDesignCanvasV2: Fabric.js-based product customization canvas
  - Lazy-loads Fabric.js to minimize bundle impact
  - Renders product mockup with print area guide (magenta dashed box)
  - Supports front/back view switching with correct product images
  - Color swatch selection for garment customization
  - Text element creation with selection handles and transform controls
  - Fixed React dependency array to prevent infinite renders

Support Components
- DesignCanvasWrapper: Client-side wrapper for SSR compatibility
- DesignCanvasErrorBoundary: Error boundary for canvas failures
- ThemeInitializer: Theme detection and application

Configuration
- Updated .claude/launch.json with dev server settings
- Updated .claude/settings.local.json with local preferences
- Updated next.config.mjs for production build optimization
- Dependency updates in package.json and package-lock.json

Known Issues & TODO
- Image element rendering disabled (Fabric.js compatibility investigation)
- Text editing UI controls needed (font, size, color pickers)
- Delete/undo functionality pending
- Print area alignment needs visual verification
- "Add to Cart" integration with design serialization pending

Testing Artifacts
- check_*.js: Product verification scripts for debugging

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-22 16:20:40 +01:00
AndymickandClaude Haiku 4.5 d41aebc4f1 Refactor: Implement Fabric.js-based design canvas (FabricDesignCanvasV2)
Replaced the problematic native Canvas implementation with Fabric.js v7.4.0 for better handling of:
- Text and image manipulation with selection handles
- Print area visualization with magenta dashed guide box
- Front/back view switching with correct product images
- Color swatch selection for garment colors
- Drag-and-drop positioning with proper coordinate system

Key features:
- Lazy-loads Fabric.js to minimize bundle size impact
- Renders product mockup images as background layer
- Displays print area boundaries as selectable zone guide
- Text elements with font/size/color support (images pending)
- Proper React hydration handling with client-only initialization

Known limitations:
- Image element rendering disabled temporarily (Fabric.js compatibility investigation needed)
- Focus on text editing and print area alignment for MVP

Files:
- FabricDesignCanvasV2.tsx: Main canvas component
- DesignCanvasWrapper.tsx: Client-side wrapper for SSR compatibility

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-22 16:20:04 +01:00
AndymickandClaude Haiku 4.5 b4af94cb31 Feat: Add checkout options modal after adding design to bag
- Show modal after design is approved and added to bag
- Offer two options: 'Go to Checkout' and 'Keep Shopping'
- 'Go to Checkout' navigates to /checkout
- 'Keep Shopping' closes the modal and lets customer continue

Improved UX - no more confusing confirmation dialogs, just
a clean modal with clear next steps.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-22 05:46:11 +01:00
AndymickandClaude Haiku 4.5 9dbb288fb3 Fix: Approve & Add to Bag button not working
- Add JSON body to approve API request
- Add Content-Type header
- Add error handling with user-friendly messages
- Log errors to console for debugging

The button now properly sends the approval request and adds
the design to the customer's bag.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-22 05:44:00 +01:00
AndymickandClaude Haiku 4.5 bdb5955447 Feat: Show estimated delivery date on design review page
- Add expectedDeliveryDate to DesignApproval interface
- Display estimated delivery date in Order Summary section
- Shows the date set by admin when approving the design

Customers can now see when their design will be delivered
before adding it to their bag.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-22 05:42:25 +01:00
AndymickandClaude Haiku 4.5 e4a84b1979 Fix: Link approved designs to review page, not personalization
Changed link from /made-to-order/... to /designs/[id]/review

This takes customers to the design review page where they can:
- See the approved design
- Click 'Approve' to add it to their bag
- Proceed to checkout

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-22 05:41:04 +01:00
AndymickandClaude Haiku 4.5 0f119b5db5 Fix: Show approved designs in customer account so they can order
- Add query for approved designs (status = 'APPROVED')
- Display 'Ready to order' section in customer account
- Link directly to checkout with design pre-selected
- Show estimated delivery date if set
- Add success message when admin approves design

Now when admin approves a design with expected delivery date:
1. Customer sees it in 'Ready to order' section
2. Can click to view and proceed to payment
3. Can see estimated delivery date

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-22 05:38:51 +01:00
AndymickandClaude Haiku 4.5 234f03b4dd Revert: Remove loading spinner - was slowing down page
Suspense boundary added complexity and extra queries, making pages slower.
Removed the LoadingSpinner component and Suspense wrapping.

Reverted to simpler, faster approach.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-21 19:32:31 +01:00
AndymickandClaude Haiku 4.5 dc9bceb494 Feat: Add loading spinner for made-to-order page
- Create LoadingSpinner component with spinning circle
- Wrap products grid with Suspense boundary
- Shows spinner while products are loading
- Improves UX for slower connections

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-21 19:30:32 +01:00
AndymickandClaude Haiku 4.5 e716e8256c Feat: Remove all filters from products pages
Remove search, category, color, and price filters from products and
made-to-order pages. Keep only simple pagination for browsing.

Simplifies UX and removes unnecessary database queries:
- No category/collection queries needed
- No palette generation
- No filter logic

Much cleaner pages with faster load times.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-21 19:26:23 +01:00
66 changed files with 3635 additions and 830 deletions
+2 -1
View File
@@ -5,7 +5,8 @@
"name": "dev", "name": "dev",
"runtimeExecutable": "npm", "runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"], "runtimeArgs": ["run", "dev"],
"port": 3000 "port": 3000,
"autoPort": true
} }
] ]
} }
+29 -1
View File
@@ -159,7 +159,35 @@
"Bash(curl -s http://127.0.0.1:3000/products/awd-hoodie)", "Bash(curl -s http://127.0.0.1:3000/products/awd-hoodie)",
"Bash(curl -s http://127.0.0.1:3000/made-to-order)", "Bash(curl -s http://127.0.0.1:3000/made-to-order)",
"Bash(curl -s \"http://127.0.0.1:3000/products/awd-hoodie\")", "Bash(curl -s \"http://127.0.0.1:3000/products/awd-hoodie\")",
"Bash(git commit -m 'Fix: NavDropdown link navigation with stopPropagation *)" "Bash(git commit -m 'Fix: NavDropdown link navigation with stopPropagation *)",
"Bash(git commit -m 'Feat: Remove all filters from products pages *)",
"Bash(git commit -m 'Feat: Add loading spinner for made-to-order page *)",
"Bash(git commit -m 'Revert: Remove loading spinner - was slowing down page *)",
"Bash(git commit -m 'Fix: Show approved designs in customer account so they can order *)",
"Bash(git commit -m 'Fix: Link approved designs to review page, not personalization *)",
"Bash(git commit -m 'Feat: Show estimated delivery date on design review page *)",
"Bash(git commit -m 'Fix: Approve & Add to Bag button not working *)",
"Bash(git commit -m 'Feat: Add checkout options modal after adding design to bag *)",
"PowerShell(Get-Process node -ErrorAction SilentlyContinue)",
"PowerShell(Stop-Process -Force -ErrorAction SilentlyContinue)",
"PowerShell(npx prisma generate)",
"Bash(npm view *)",
"Bash(nc -zv 192.168.0.190 5432)",
"Bash(psql --version)",
"Bash(ssh -o StrictHostKeyChecking=no postgres@192.168.0.190 \"psql -U postgres -c 'SHOW max_connections;'\")",
"Bash(ssh -p 22 -o StrictHostKeyChecking=no postgres@192.168.0.190 \"psql -U postgres -c 'SHOW max_connections;'\")",
"Bash(ssh -p 22 andymick01@192.168.0.190 \"sudo -u postgres psql -c 'SHOW max_connections;'\")",
"Bash(ssh -i ~/.ssh/sshkey -p 22 andymick01@192.168.0.190 \"sudo -u postgres psql -c 'SHOW max_connections;'\")",
"Bash(ssh -vv -i ~/.ssh/sshkey -p 22 andymick01@192.168.0.190 \"echo test\")",
"Bash(ssh git@192.168.0.190 \"sudo -u postgres psql -c 'SHOW max_connections;'\")",
"Bash(ssh -p 22 git@192.168.0.190 \"sudo -u postgres psql -c 'SHOW max_connections;'\")",
"Bash(ssh -i ~/.ssh/id_ed25519 -p 22 git@192.168.0.190 \"sudo -u postgres psql -c 'SHOW max_connections;'\")",
"Bash(psql -h 192.168.0.190 -p 6432 -U postgres -d craft2prints -c \"SELECT 1 as connection_test;\")",
"Bash(npm list *)",
"Bash(npm ls *)",
"Bash(node -e \"const mod = require\\('fabric'\\); console.log\\(Object.keys\\(mod\\).slice\\(0, 20\\)\\)\")",
"Bash(timeout 5 curl -s http://localhost:3000)",
"Bash(curl -s http://localhost:3000/made-to-order/awd-t-shirt-at001)"
] ]
} }
} }
+41
View File
@@ -0,0 +1,41 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const product = await prisma.product.findUnique({
where: { slug: 'awd-t-shirt-at001' },
select: {
colorPhotos: true,
colorPhotosBack: true
}
});
const front = JSON.parse(product.colorPhotos);
const back = JSON.parse(product.colorPhotosBack || '{}');
console.log('Front colors:', Object.keys(front).sort());
console.log('Back colors:', Object.keys(back).sort());
const allColors = new Set([...Object.keys(front), ...Object.keys(back)]);
console.log('\nMissing in back:');
Object.keys(front).forEach(color => {
if (!back[color]) {
console.log(` ${color} (front has it, back is MISSING)`);
}
});
console.log('\nExtra in back (no front):');
Object.keys(back).forEach(color => {
if (!front[color]) {
console.log(` ${color} (back has it, front is missing)`);
}
});
}
main()
.then(() => process.exit(0))
.catch(err => {
console.error(err);
process.exit(1);
});
+38
View File
@@ -0,0 +1,38 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const products = await prisma.product.findMany({
where: {
slug: {
in: ['awd-t-shirt-at001', 'awd-hoodie']
}
},
select: {
'slug': true,
'name': true,
'imageUrl': true,
'imageUrlBack': true,
'colorPhotos': true,
'colorPhotosBack': true,
'printArea': true,
'printAreaBack': true
}
});
products.forEach(p => {
console.log(`\n${p.name} (${p.slug}):`);
console.log(` Base Images: front=${!!p.imageUrl}, back=${!!p.imageUrlBack}`);
const colorPhotos = p.colorPhotos ? JSON.parse(p.colorPhotos) : {};
const colorPhotosBack = p.colorPhotosBack ? JSON.parse(p.colorPhotosBack) : {};
console.log(` Color Photos: front=${Object.keys(colorPhotos).length}, back=${Object.keys(colorPhotosBack).length}`);
console.log(` Print Areas: front=${!!p.printArea}, back=${!!p.printAreaBack}`);
});
}
main()
.then(() => process.exit(0))
.catch(err => {
console.error(err);
process.exit(1);
});
+37
View File
@@ -0,0 +1,37 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const products = await prisma.product.findMany({
where: {
slug: {
in: ['awd-t-shirt-at001', 'awd-hoodie']
}
},
select: {
'slug': true,
'name': true,
'printArea': true,
'printAreaBack': true,
'mockup': true
}
});
products.forEach(p => {
console.log(`\n${p.name} (${p.slug}):`);
console.log(` Mockup: ${p.mockup}`);
const front = JSON.parse(p.printArea);
console.log(` Front Print Area: x=${front.xPct}, y=${front.yPct}, w=${front.wPct}, h=${front.hPct}`);
if (p.printAreaBack) {
const back = JSON.parse(p.printAreaBack);
console.log(` Back Print Area: x=${back.xPct}, y=${back.yPct}, w=${back.wPct}, h=${back.hPct}`);
}
});
}
main()
.then(() => process.exit(0))
.catch(err => {
console.error(err);
process.exit(1);
});
+42
View File
@@ -0,0 +1,42 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const product = await prisma.product.findUnique({
where: { slug: 'awd-t-shirt-at001' }
});
if (!product) {
console.log('Product not found');
return;
}
console.log('\n=== T-Shirt Images ===');
console.log(`\nColors: ${product.colors}`);
console.log(`\nimagUrl: ${product.imageUrl ? 'SET' : 'NOT SET'}`);
console.log(`imageUrlBack: ${product.imageUrlBack ? 'SET' : 'NOT SET'}`);
if (product.colorPhotos) {
const colors = JSON.parse(product.colorPhotos);
console.log(`\ncolorPhotos: ${Object.keys(colors).length} colors have photos`);
Object.keys(colors).forEach(color => {
console.log(` - ${color}: ${colors[color] ? 'HAS IMAGE' : 'NO IMAGE'}`);
});
} else {
console.log('\ncolorPhotos: NOT SET');
}
if (product.colorPhotosBack) {
const colors = JSON.parse(product.colorPhotosBack);
console.log(`\ncolorPhotosBack: ${Object.keys(colors).length} colors have back photos`);
} else {
console.log('\ncolorPhotosBack: NOT SET');
}
}
main()
.then(() => process.exit(0))
.catch(err => {
console.error(err);
process.exit(1);
});
+32
View File
@@ -0,0 +1,32 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const products = await prisma.product.findMany({
where: {
slug: {
in: ['awd-t-shirt-at001', 'awd-hoodie']
}
},
select: {
'id': true,
'slug': true,
'name': true,
'imageUrl': true,
'imageUrlBack': true
}
});
products.forEach(p => {
console.log(`\n${p.name} (${p.slug}):`);
console.log(` Front: ${p.imageUrl ? p.imageUrl.substring(0, 60) + '...' : 'NOT SET'}`);
console.log(` Back: ${p.imageUrlBack ? p.imageUrlBack.substring(0, 60) + '...' : 'NOT SET'}`);
});
}
main()
.then(() => process.exit(0))
.catch(err => {
console.error(err);
process.exit(1);
});
+2 -1
View File
@@ -1,5 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+1 -2
View File
@@ -1,9 +1,8 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
eslint: { ignoreDuringBuilds: true }, serverExternalPackages: ['@prisma/client'],
experimental: { experimental: {
serverComponentsExternalPackages: ['@prisma/client'],
// Invoice uploads (phone photos/PDFs) and product photo uploads go through // Invoice uploads (phone photos/PDFs) and product photo uploads go through
// server actions — the 1MB default rejects typical phone photos. // server actions — the 1MB default rejects typical phone photos.
serverActions: { bodySizeLimit: '10mb' }, serverActions: { bodySizeLimit: '10mb' },
+1678 -102
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -14,10 +14,11 @@
"dependencies": { "dependencies": {
"@prisma/client": "5.16.1", "@prisma/client": "5.16.1",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"fabric": "^7.4.0",
"idb-keyval": "6.2.1", "idb-keyval": "6.2.1",
"jose": "^6.2.3", "jose": "^6.2.3",
"konva": "9.3.14", "konva": "9.3.14",
"next": "14.2.5", "next": "^16.2.11",
"nodemailer": "6.9.14", "nodemailer": "6.9.14",
"pdf-parse": "^2.4.5", "pdf-parse": "^2.4.5",
"react": "18.3.1", "react": "18.3.1",
+5 -4
View File
@@ -37,7 +37,7 @@ export async function registerCustomer(formData: FormData) {
if (password !== confirmPassword) redirect('/account/register?error=mismatch'); if (password !== confirmPassword) redirect('/account/register?error=mismatch');
if (password.length < 8) redirect('/account/register?error=weak'); if (password.length < 8) redirect('/account/register?error=weak');
const ip = getClientIp(headers()); const ip = getClientIp(await headers());
const turnstileToken = String(formData.get('cf-turnstile-response') ?? ''); const turnstileToken = String(formData.get('cf-turnstile-response') ?? '');
const { success } = await verifyTurnstileToken(turnstileToken, ip); const { success } = await verifyTurnstileToken(turnstileToken, ip);
if (!success) redirect('/account/register?error=captcha'); if (!success) redirect('/account/register?error=captcha');
@@ -61,8 +61,9 @@ export async function loginCustomer(formData: FormData) {
const password = String(formData.get('password') ?? ''); const password = String(formData.get('password') ?? '');
const next = safeNext(formData.get('next')); const next = safeNext(formData.get('next'));
const ip = getClientIp(headers()); const headersList = await headers();
const userAgent = headers().get('user-agent') ?? undefined; const ip = getClientIp(headersList);
const userAgent = headersList.get('user-agent') ?? undefined;
const { allowed } = await checkRateLimit(`login:customer:${ip}`, 5, 15 * 60 * 1000); const { allowed } = await checkRateLimit(`login:customer:${ip}`, 5, 15 * 60 * 1000);
if (!allowed) { if (!allowed) {
@@ -95,7 +96,7 @@ export async function loginCustomer(formData: FormData) {
} }
export async function logoutCustomer() { export async function logoutCustomer() {
clearSession(); await clearSession();
redirect('/'); redirect('/');
} }
+39
View File
@@ -47,6 +47,12 @@ export default async function AccountPage({ searchParams }: { searchParams: { su
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
const approvedDesigns = await prisma.designApproval.findMany({
where: { customerEmail: customer.email, status: 'APPROVED' },
include: { product: true },
orderBy: { createdAt: 'desc' },
});
// Separate recent (< 7 days) and older (>= 7 days) orders // Separate recent (< 7 days) and older (>= 7 days) orders
const now = new Date(); const now = new Date();
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
@@ -121,6 +127,39 @@ export default async function AccountPage({ searchParams }: { searchParams: { su
</div> </div>
)} )}
<h2 className="mt-10 font-display text-xl">Ready to order</h2>
{approvedDesigns.length === 0 ? (
<p className="mt-3 text-sm text-muted">No approved designs ready to order.</p>
) : (
<div className="mt-4 divide-y divide-line border-t border-line">
{approvedDesigns.map((design) => (
<Link
key={design.id}
href={`/designs/${design.id}/review`}
className="flex items-center gap-4 py-4 hover:bg-surface"
>
<img
src={design.designPreviewUrl}
alt="design preview"
className="h-14 w-14 border border-line object-cover"
/>
<div className="flex-1">
<p className="font-medium">{design.product.name}</p>
<p className="text-sm text-muted">
Approved {new Date(design.createdAt).toLocaleDateString(undefined, { dateStyle: 'medium' })}
</p>
{design.expectedDeliveryDate && (
<p className="text-xs text-muted mt-1">
Est. delivery: {new Date(design.expectedDeliveryDate).toLocaleDateString(undefined, { dateStyle: 'medium' })}
</p>
)}
</div>
<span className="text-sm text-clay">Order </span>
</Link>
))}
</div>
)}
<h2 className="mt-10 font-display text-xl">Order history</h2> <h2 className="mt-10 font-display text-xl">Order history</h2>
{orders.length === 0 ? ( {orders.length === 0 ? (
<p className="mt-3 text-sm text-muted">No orders yet.</p> <p className="mt-3 text-sm text-muted">No orders yet.</p>
+1 -1
View File
@@ -15,7 +15,7 @@ export async function subscribeToNewsletter(email: string, turnstileToken: strin
return { ok: false, message: 'Enter a valid email address.' }; return { ok: false, message: 'Enter a valid email address.' };
} }
const ip = getClientIp(headers()); const ip = getClientIp(await headers());
const { success } = await verifyTurnstileToken(turnstileToken, ip); const { success } = await verifyTurnstileToken(turnstileToken, ip);
if (!success) { if (!success) {
return { ok: false, message: 'CAPTCHA verification failed — please try again.' }; return { ok: false, message: 'CAPTCHA verification failed — please try again.' };
+6 -6
View File
@@ -33,8 +33,8 @@ export default async function AccountsPage({
// Include the whole "to" day, not just its midnight. // Include the whole "to" day, not just its midnight.
const to = searchParams.to ? new Date(`${searchParams.to}T23:59:59`) : null; const to = searchParams.to ? new Date(`${searchParams.to}T23:59:59`) : null;
let orders = []; let orders: any[] = [];
let manualSales = []; let manualSales: any[] = [];
try { try {
[orders, manualSales] = await Promise.all([ [orders, manualSales] = await Promise.all([
@@ -59,22 +59,22 @@ export default async function AccountsPage({
} }
const entries: LedgerEntry[] = [ const entries: LedgerEntry[] = [
...orders.map((o) => ({ ...orders.map((o: any) => ({
id: o.id, id: o.id,
kind: 'WEB' as const, kind: 'WEB' as const,
date: o.createdAt, date: o.createdAt,
who: o.customer?.name ?? o.email ?? 'Guest', who: o.customer?.name ?? o.email ?? 'Guest',
what: o.items.map((i) => `${i.quantity}× ${i.productName}`).join(', '), what: o.items.map((i: any) => `${i.quantity}× ${i.productName}`).join(', '),
method: 'Card (website)', method: 'Card (website)',
total: o.total, total: o.total,
href: `/admin/orders`, href: `/admin/orders`,
})), })),
...manualSales.map((s) => ({ ...manualSales.map((s: any) => ({
id: s.id, id: s.id,
kind: 'MANUAL' as const, kind: 'MANUAL' as const,
date: s.soldAt, date: s.soldAt,
who: s.buyerName || s.buyerEmail || 'Unnamed buyer', who: s.buyerName || s.buyerEmail || 'Unnamed buyer',
what: s.items.map((i) => `${i.quantity}× ${i.description}`).join(', '), what: s.items.map((i: any) => `${i.quantity}× ${i.description}`).join(', '),
method: PAYMENT_LABELS[s.paymentMethod] ?? s.paymentMethod, method: PAYMENT_LABELS[s.paymentMethod] ?? s.paymentMethod,
total: s.total, total: s.total,
href: null, href: null,
+4 -4
View File
@@ -4,8 +4,8 @@ import AccountsNav from '@/components/AccountsNav';
import StockAdjustForm from '@/components/StockAdjustForm'; import StockAdjustForm from '@/components/StockAdjustForm';
export default async function StockPage() { export default async function StockPage() {
let levels = []; let levels: any[] = [];
let productRows = []; let productRows: any[] = [];
try { try {
[levels, productRows] = await Promise.all([ [levels, productRows] = await Promise.all([
@@ -18,7 +18,7 @@ export default async function StockPage() {
console.error('Error fetching stock data:', err); console.error('Error fetching stock data:', err);
} }
const products = productRows.map((p) => ({ const products = productRows.map((p: any) => ({
id: p.id, id: p.id,
name: p.name, name: p.name,
colors: JSON.parse(p.colors) as string[], colors: JSON.parse(p.colors) as string[],
@@ -27,7 +27,7 @@ export default async function StockPage() {
// Group rows under their product for a readable table. // Group rows under their product for a readable table.
const grouped = new Map<string, typeof levels>(); const grouped = new Map<string, typeof levels>();
for (const level of levels.sort((a, b) => { for (const level of levels.sort((a: any, b: any) => {
if (a.product.name !== b.product.name) return a.product.name.localeCompare(b.product.name); if (a.product.name !== b.product.name) return a.product.name.localeCompare(b.product.name);
if (a.color !== b.color) return a.color.localeCompare(b.color); if (a.color !== b.color) return a.color.localeCompare(b.color);
return (a.size || '').localeCompare(b.size || ''); return (a.size || '').localeCompare(b.size || '');
+3 -3
View File
@@ -14,14 +14,14 @@ export default async function CustomersPage({
}: { }: {
searchParams: { sent?: string; failed?: string }; searchParams: { sent?: string; failed?: string };
}) { }) {
let entries = []; let entries: any[] = [];
try { try {
entries = await prisma.mailingListEntry.findMany(); entries = await prisma.mailingListEntry.findMany();
entries.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); entries.sort((a: any, b: any) => b.createdAt.getTime() - a.createdAt.getTime());
} catch (err) { } catch (err) {
console.error('Error fetching mailing list:', err); console.error('Error fetching mailing list:', err);
} }
const subscribedCount = entries.filter((e) => e.subscribed).length; const subscribedCount = entries.filter((e: any) => e.subscribed).length;
return ( return (
<div className="mx-auto max-w-3xl px-6 py-12"> <div className="mx-auto max-w-3xl px-6 py-12">
+2 -21
View File
@@ -4,33 +4,14 @@ import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import type { ProductDTO } from '@/lib/types';
interface DesignApproval { interface DesignApproval {
id: string; id: string;
customerEmail: string; customerEmail: string;
customerName: string | null; customerName: string | null;
productId: string; productId: string;
product: { product: ProductDTO;
id: string;
name: string;
basePrice: number;
personalisedPrice: number;
onSale: boolean;
effectivePrice: number;
colors: string[];
sizes: string[];
showOnPersonalised: boolean;
imageUrl: string | null;
imageUrlBack: string | null;
photoRecolorable: boolean;
colorPhotos: Record<string, string>;
colorPhotosBack: Record<string, string>;
printArea: string;
printAreaBack: string | null;
mockup: string;
referenceWidthCm: number | null;
referenceHeightCm: number | null;
};
designJson: string; designJson: string;
color: string; color: string;
size: string | null; size: string | null;
+1
View File
@@ -57,6 +57,7 @@ export default function DesignDetailPage({ params }: { params: { id: string } })
if (res.ok) { if (res.ok) {
const updated = await res.json(); const updated = await res.json();
setDesign(updated); setDesign(updated);
alert(`✓ Design approved! Customer will receive an email with a link to order.${expectedDeliveryDate ? ` Estimated delivery: ${new Date(expectedDeliveryDate).toLocaleDateString()}` : ''}`);
} }
} finally { } finally {
setApproving(false); setApproving(false);
+7 -7
View File
@@ -4,7 +4,7 @@ import AdminNav from '@/components/AdminNav';
import DeleteDesignButton from '@/components/DeleteDesignButton'; import DeleteDesignButton from '@/components/DeleteDesignButton';
export default async function DesignsPage() { export default async function DesignsPage() {
let designs = []; let designs: any[] = [];
try { try {
designs = await prisma.designApproval.findMany({ designs = await prisma.designApproval.findMany({
select: { select: {
@@ -20,15 +20,15 @@ export default async function DesignsPage() {
}); });
// Fetch products separately to avoid relation issues // Fetch products separately to avoid relation issues
const productIds = [...new Set(designs.map(d => d.productId))]; const productIds = [...new Set(designs.map((d: any) => d.productId))];
const products = await prisma.product.findMany({ const products = await prisma.product.findMany({
where: { id: { in: productIds } }, where: { id: { in: productIds } },
select: { id: true, name: true }, select: { id: true, name: true },
}); });
const productMap = Object.fromEntries(products.map(p => [p.id, p])); const productMap = Object.fromEntries(products.map((p: any) => [p.id, p]));
// Add product to each design // Add product to each design
designs = designs.map(d => ({ designs = designs.map((d: any) => ({
...d, ...d,
product: productMap[d.productId], product: productMap[d.productId],
messages: [], messages: [],
@@ -37,9 +37,9 @@ export default async function DesignsPage() {
console.error('Error fetching designs:', err); console.error('Error fetching designs:', err);
} }
const pending = designs.filter((d) => d.status === 'PENDING'); const pending = designs.filter((d: any) => d.status === 'PENDING');
const approved = designs.filter((d) => d.status === 'APPROVED'); const approved = designs.filter((d: any) => d.status === 'APPROVED');
const rejected = designs.filter((d) => d.status === 'REJECTED'); const rejected = designs.filter((d: any) => d.status === 'REJECTED');
const DesignList = ({ items, status }: { items: typeof designs; status: string }) => ( const DesignList = ({ items, status }: { items: typeof designs; status: string }) => (
<div className="mt-8"> <div className="mt-8">
+3 -2
View File
@@ -17,8 +17,9 @@ export async function loginAdmin(formData: FormData) {
const password = String(formData.get('password') ?? ''); const password = String(formData.get('password') ?? '');
const next = safeNext(formData.get('next')); const next = safeNext(formData.get('next'));
const ip = getClientIp(headers()); const headersList = await headers();
const userAgent = headers().get('user-agent') ?? undefined; const ip = getClientIp(headersList);
const userAgent = headersList.get('user-agent') ?? undefined;
const { allowed } = await checkRateLimit(`login:admin:${ip}`, 5, 15 * 60 * 1000); const { allowed } = await checkRateLimit(`login:admin:${ip}`, 5, 15 * 60 * 1000);
if (!allowed) { if (!allowed) {
+3 -3
View File
@@ -5,9 +5,9 @@ const ERROR_MESSAGES: Record<string, string> = {
rate_limited: 'Too many attempts — please wait a few minutes and try again.', rate_limited: 'Too many attempts — please wait a few minutes and try again.',
}; };
export default function AdminLoginPage({ searchParams }: { searchParams: { error?: string; next?: string } }) { export default async function AdminLoginPage({ searchParams }: { searchParams: Promise<{ error?: string; next?: string }> }) {
const error = searchParams.error ? (ERROR_MESSAGES[searchParams.error] ?? 'Something went wrong.') : null; const { error: errorKey, next } = await searchParams;
const next = searchParams.next ?? '/admin/dashboard'; const error = errorKey ? (ERROR_MESSAGES[errorKey] ?? 'Something went wrong.') : null;
return ( return (
<div className="mx-auto max-w-md px-6 py-16"> <div className="mx-auto max-w-md px-6 py-16">
+2 -1
View File
@@ -237,8 +237,9 @@ export default async function OrderDetailPage({ params }: { params: { id: string
{order.items.map((item) => { {order.items.map((item) => {
let hasFrontDesign = false; let hasFrontDesign = false;
let hasBackDesign = false; let hasBackDesign = false;
let design: { front: unknown[]; back: unknown[] } = { front: [], back: [] };
try { try {
const design = JSON.parse(item.designJson) as { front: unknown[]; back: unknown[] }; design = JSON.parse(item.designJson) as { front: unknown[]; back: unknown[] };
hasFrontDesign = (design.front?.length ?? 0) > 0; hasFrontDesign = (design.front?.length ?? 0) > 0;
hasBackDesign = (design.back?.length ?? 0) > 0; hasBackDesign = (design.back?.length ?? 0) > 0;
} catch { } catch {
+5 -5
View File
@@ -37,7 +37,7 @@ export default async function OrdersPage({ searchParams }: { searchParams: { arc
const pageSize = 50; const pageSize = 50;
const skip = (page - 1) * pageSize; const skip = (page - 1) * pageSize;
let orders = []; let orders: any[] = [];
let total = 0; let total = 0;
try { try {
// Fetch orders and total count in parallel // Fetch orders and total count in parallel
@@ -80,8 +80,8 @@ export default async function OrdersPage({ searchParams }: { searchParams: { arc
// Filter to personalised orders if requested // Filter to personalised orders if requested
if (showOnlyPersonalised) { if (showOnlyPersonalised) {
orders = orders.filter((order) => orders = orders.filter((order: any) =>
order.items.some((item) => { order.items.some((item: any) => {
const design = JSON.parse(item.designJson) as { front: unknown[]; back: unknown[] }; const design = JSON.parse(item.designJson) as { front: unknown[]; back: unknown[] };
return design.front.length > 0 || design.back.length > 0; return design.front.length > 0 || design.back.length > 0;
}) })
@@ -148,8 +148,8 @@ export default async function OrdersPage({ searchParams }: { searchParams: { arc
</p> </p>
) : ( ) : (
<div className="mt-8 divide-y divide-line border-t border-line"> <div className="mt-8 divide-y divide-line border-t border-line">
{orders.map((order) => { {orders.map((order: any) => {
const hasPersonalised = order.items.some((item) => { const hasPersonalised = order.items.some((item: any) => {
try { try {
const design = JSON.parse(item.designJson) as { front: unknown[]; back: unknown[] }; const design = JSON.parse(item.designJson) as { front: unknown[]; back: unknown[] };
return (design.front?.length ?? 0) > 0 || (design.back?.length ?? 0) > 0; return (design.front?.length ?? 0) > 0 || (design.back?.length ?? 0) > 0;
+11 -10
View File
@@ -9,25 +9,22 @@ import SizePicker from '@/components/SizePicker';
import PhotoPrintAreaField, { type Box } from '@/components/PhotoPrintAreaField'; import PhotoPrintAreaField, { type Box } from '@/components/PhotoPrintAreaField';
import CategoryPicker from '@/components/CategoryPicker'; import CategoryPicker from '@/components/CategoryPicker';
export default async function EditProductPage({ params }: { params: { id: string } }) { export default async function EditProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const activePromotions = await fetchActivePromotions(); const activePromotions = await fetchActivePromotions();
const row = await prisma.product.findUnique({ where: { id: params.id }, include: { collections: true } }); const row = await prisma.product.findUnique({ where: { id }, include: { collections: true } });
if (!row) notFound(); if (!row) notFound();
const product = toProductDTO(row, activePromotions); const product = toProductDTO(row, activePromotions);
const categories = await prisma.category.findMany({ const categories = await prisma.category.findMany({
where: { parentId: null }, where: { parentId: null },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
select: { include: {
id: true,
name: true,
slug: true,
children: { children: {
select: { id: true, name: true, slug: true },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
}, },
}, },
}); }) as any;
const collections = await prisma.collection.findMany({ orderBy: { name: 'asc' } }); const collections = await prisma.collection.findMany({ orderBy: { name: 'asc' } });
const occasions = collections.filter((c) => c.group === 'OCCASION'); const occasions = collections.filter((c) => c.group === 'OCCASION');
const recipients = collections.filter((c) => c.group === 'RECIPIENT'); const recipients = collections.filter((c) => c.group === 'RECIPIENT');
@@ -49,6 +46,10 @@ export default async function EditProductPage({ params }: { params: { id: string
hPct: printAreaBack.hPct * 100, hPct: printAreaBack.hPct * 100,
} : undefined; } : undefined;
// Parse color photos for preview if no base image
const colorPhotos = row.colorPhotos ? JSON.parse(row.colorPhotos) : undefined;
const colorPhotosBack = row.colorPhotosBack ? JSON.parse(row.colorPhotosBack) : undefined;
return ( return (
<div className="mx-auto max-w-2xl px-6 py-12"> <div className="mx-auto max-w-2xl px-6 py-12">
<AdminNav /> <AdminNav />
@@ -279,12 +280,12 @@ export default async function EditProductPage({ params }: { params: { id: string
<div> <div>
<label className="tag-label mb-2 block">Front photo</label> <label className="tag-label mb-2 block">Front photo</label>
<PhotoPrintAreaField existingImageUrl={product.imageUrl} existingPrintArea={existingPrintArea} /> <PhotoPrintAreaField existingImageUrl={product.imageUrl ?? undefined} existingPrintArea={existingPrintArea} colorPhotosPreview={colorPhotos} />
</div> </div>
<div> <div>
<label className="tag-label mb-2 block">Back photo</label> <label className="tag-label mb-2 block">Back photo</label>
<PhotoPrintAreaField variant="back" existingImageUrl={product.imageUrlBack} existingPrintArea={existingPrintAreaBack} /> <PhotoPrintAreaField variant="back" existingImageUrl={product.imageUrlBack ?? undefined} existingPrintArea={existingPrintAreaBack} colorPhotosPreview={colorPhotosBack} />
</div> </div>
<div> <div>
+3 -3
View File
@@ -276,10 +276,10 @@ export async function updateProduct(formData: FormData) {
referenceWidthCm: number | null; referenceWidthCm: number | null;
referenceHeightCm: number | null; referenceHeightCm: number | null;
weightGrams: number | null; weightGrams: number | null;
imageUrl?: string; imageUrl?: string | null;
imageUrlBack?: string; imageUrlBack?: string | null;
printArea?: string; printArea?: string;
printAreaBack?: string; printAreaBack?: string | null;
} = { } = {
name, name,
category, category,
+2 -6
View File
@@ -10,16 +10,12 @@ export default async function NewProductPage() {
const categories = await prisma.category.findMany({ const categories = await prisma.category.findMany({
where: { parentId: null }, where: { parentId: null },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
select: { include: {
id: true,
name: true,
slug: true,
children: { children: {
select: { id: true, name: true, slug: true },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
}, },
}, },
}); }) as any;
const collections = await prisma.collection.findMany({ orderBy: { name: 'asc' } }); const collections = await prisma.collection.findMany({ orderBy: { name: 'asc' } });
const occasions = collections.filter((c) => c.group === 'OCCASION'); const occasions = collections.filter((c) => c.group === 'OCCASION');
const recipients = collections.filter((c) => c.group === 'RECIPIENT'); const recipients = collections.filter((c) => c.group === 'RECIPIENT');
+5 -4
View File
@@ -9,13 +9,14 @@ import { deleteProduct } from './actions';
export default async function AdminProductsPage({ export default async function AdminProductsPage({
searchParams, searchParams,
}: { }: {
searchParams: { error?: string }; searchParams: Promise<{ error?: string }>;
}) { }) {
const { error } = await searchParams;
const activePromotions = await fetchActivePromotions(); const activePromotions = await fetchActivePromotions();
let products = []; let products: any[] = [];
try { try {
const rows = await prisma.product.findMany(); const rows = await prisma.product.findMany();
products = rows.map((p) => toProductDTO(p, activePromotions)).sort((a, b) => products = rows.map((p: any) => toProductDTO(p, activePromotions)).sort((a: any, b: any) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
); );
} catch (err) { } catch (err) {
@@ -35,7 +36,7 @@ export default async function AdminProductsPage({
</Link> </Link>
</div> </div>
{searchParams.error === 'in_use' && ( {error === 'in_use' && (
<div className="mt-6 border border-splash-orange bg-splash-orange/5 p-4 text-sm"> <div className="mt-6 border border-splash-orange bg-splash-orange/5 p-4 text-sm">
Can&apos;t delete that product it has existing orders or design proofs tied to it. Can&apos;t delete that product it has existing orders or design proofs tied to it.
</div> </div>
+2 -2
View File
@@ -130,7 +130,7 @@ export async function POST(req: Request) {
// Calculate shipping cost (skip for collection orders) // Calculate shipping cost (skip for collection orders)
let shippingCost = 0; let shippingCost = 0;
let selectedShippingService = shippingService; let selectedShippingService: string | undefined | null = shippingService;
if (isCollection) { if (isCollection) {
console.log('Collection order - no shipping cost'); console.log('Collection order - no shipping cost');
@@ -193,7 +193,7 @@ export async function POST(req: Request) {
email: guest?.email ?? customer?.email, email: guest?.email ?? customer?.email,
guestName: guest?.name, guestName: guest?.name,
guestPhone: guest?.phone, guestPhone: guest?.phone,
isCollectionPickup, isCollectionPickup: isCollection,
items: { items: {
create: pricedItems.map((i) => ({ create: pricedItems.map((i) => ({
productId: i.productId, productId: i.productId,
+4 -3
View File
@@ -5,14 +5,15 @@ import { toProductDTO } from '@/lib/product';
export async function POST( export async function POST(
req: Request, req: Request,
{ params }: { params: { id: string } } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const { id } = await params;
const body = await req.json(); const body = await req.json();
const { expectedDeliveryDate } = body; const { expectedDeliveryDate } = body;
const design = await prisma.designApproval.findUnique({ const design = await prisma.designApproval.findUnique({
where: { id: params.id }, where: { id },
include: { product: true }, include: { product: true },
}); });
@@ -21,7 +22,7 @@ export async function POST(
} }
const updated = await prisma.designApproval.update({ const updated = await prisma.designApproval.update({
where: { id: params.id }, where: { id },
data: { data: {
status: 'APPROVED', status: 'APPROVED',
expectedDeliveryDate: expectedDeliveryDate ? new Date(expectedDeliveryDate) : null, expectedDeliveryDate: expectedDeliveryDate ? new Date(expectedDeliveryDate) : null,
+6 -4
View File
@@ -4,11 +4,12 @@ import type { DesignApprovalMessage } from '@prisma/client';
export async function GET( export async function GET(
req: Request, req: Request,
{ params }: { params: { id: string } } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const { id } = await params;
const messages = await prisma.designApprovalMessage.findMany({ const messages = await prisma.designApprovalMessage.findMany({
where: { designId: params.id }, where: { designId: id },
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
}); });
@@ -24,9 +25,10 @@ export async function GET(
export async function POST( export async function POST(
req: Request, req: Request,
{ params }: { params: { id: string } } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const { id } = await params;
const { sender, body, role, message, messageType } = await req.json(); const { sender, body, role, message, messageType } = await req.json();
// Support both old format (sender/body) and new format (role/message/messageType) // Support both old format (sender/body) and new format (role/message/messageType)
@@ -43,7 +45,7 @@ export async function POST(
const created = await prisma.designApprovalMessage.create({ const created = await prisma.designApprovalMessage.create({
data: { data: {
designId: params.id, designId: id,
sender: finalSender, sender: finalSender,
body: finalBody, body: finalBody,
messageType: finalMessageType, messageType: finalMessageType,
+4 -3
View File
@@ -3,9 +3,10 @@ import { prisma } from '@/lib/prisma';
export async function POST( export async function POST(
req: Request, req: Request,
{ params }: { params: { id: string } } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const { id } = await params;
const { const {
designJson, designJson,
designPreviewUrl, designPreviewUrl,
@@ -17,7 +18,7 @@ export async function POST(
} = await req.json(); } = await req.json();
const design = await prisma.designApproval.findUnique({ const design = await prisma.designApproval.findUnique({
where: { id: params.id }, where: { id: id },
}); });
if (!design) { if (!design) {
@@ -25,7 +26,7 @@ export async function POST(
} }
const updated = await prisma.designApproval.update({ const updated = await prisma.designApproval.update({
where: { id: params.id }, where: { id: id },
data: { data: {
...(designJson && { designJson }), ...(designJson && { designJson }),
...(designPreviewUrl && { designPreviewUrl }), ...(designPreviewUrl && { designPreviewUrl }),
+10 -7
View File
@@ -5,11 +5,12 @@ import { toProductDTO } from '@/lib/product';
export async function GET( export async function GET(
req: Request, req: Request,
{ params }: { params: { id: string } } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const { id } = await params;
const design = await prisma.designApproval.findUnique({ const design = await prisma.designApproval.findUnique({
where: { id: params.id }, where: { id },
include: { product: true, messages: { orderBy: { createdAt: 'asc' } } }, include: { product: true, messages: { orderBy: { createdAt: 'asc' } } },
}); });
@@ -32,9 +33,10 @@ export async function GET(
export async function PATCH( export async function PATCH(
req: Request, req: Request,
{ params }: { params: { id: string } } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const { id } = await params;
const customer = await getCurrentCustomer(); const customer = await getCurrentCustomer();
if (!customer) { if (!customer) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
@@ -43,7 +45,7 @@ export async function PATCH(
const { status } = await req.json(); const { status } = await req.json();
const updated = await prisma.designApproval.update({ const updated = await prisma.designApproval.update({
where: { id: params.id }, where: { id },
data: { status }, data: { status },
include: { product: true, messages: { orderBy: { createdAt: 'asc' } } }, include: { product: true, messages: { orderBy: { createdAt: 'asc' } } },
}); });
@@ -63,17 +65,18 @@ export async function PATCH(
export async function DELETE( export async function DELETE(
req: Request, req: Request,
{ params }: { params: { id: string } } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const { id } = await params;
// Delete associated messages first // Delete associated messages first
await prisma.designApprovalMessage.deleteMany({ await prisma.designApprovalMessage.deleteMany({
where: { designId: params.id }, where: { designId: id },
}); });
// Delete the design approval // Delete the design approval
await prisma.designApproval.delete({ await prisma.designApproval.delete({
where: { id: params.id }, where: { id: id },
}); });
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
@@ -4,13 +4,14 @@ import { sendDesignReadyForReview } from '@/lib/mail';
export async function POST( export async function POST(
req: Request, req: Request,
{ params }: { params: { id: string } } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const { id } = await params;
const { designPreviewUrl, designPreviewUrlBack, placementPreviewUrl, placementPreviewUrlBack } = await req.json().catch(() => ({})); const { designPreviewUrl, designPreviewUrlBack, placementPreviewUrl, placementPreviewUrlBack } = await req.json().catch(() => ({}));
const design = await prisma.designApproval.findUnique({ const design = await prisma.designApproval.findUnique({
where: { id: params.id }, where: { id: id },
include: { product: true }, include: { product: true },
}); });
@@ -26,7 +27,7 @@ export async function POST(
if (placementPreviewUrlBack) updateData.placementPreviewUrlBack = placementPreviewUrlBack; if (placementPreviewUrlBack) updateData.placementPreviewUrlBack = placementPreviewUrlBack;
const updated = await prisma.designApproval.update({ const updated = await prisma.designApproval.update({
where: { id: params.id }, where: { id: id },
data: updateData, data: updateData,
include: { product: true, messages: { orderBy: { createdAt: 'asc' } } }, include: { product: true, messages: { orderBy: { createdAt: 'asc' } } },
}); });
@@ -36,7 +37,7 @@ export async function POST(
customerName: design.customerName || 'Customer', customerName: design.customerName || 'Customer',
customerEmail: design.customerEmail, customerEmail: design.customerEmail,
productName: design.product.name, productName: design.product.name,
reviewLink: `${process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'}/designs/${params.id}/review`, reviewLink: `${process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'}/designs/${id}/review`,
}); });
return NextResponse.json(updated); return NextResponse.json(updated);
+3 -3
View File
@@ -1,9 +1,9 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
export async function GET(req: Request, { params }: { params: { id: string } }) { export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
try { try {
const { id } = params; const { id } = await params;
const order = await prisma.order.findUnique({ const order = await prisma.order.findUnique({
where: { id }, where: { id },
include: { items: true }, include: { items: true },
@@ -292,7 +292,7 @@ export async function GET(req: Request, { params }: { params: { id: string } })
${item.designImageDimensions ? (() => { ${item.designImageDimensions ? (() => {
try { try {
const imageDims = JSON.parse(item.designImageDimensions); const imageDims = JSON.parse(item.designImageDimensions);
return imageDims && imageDims.length > 0 ? `<p><strong>Image Dimensions:</strong><ul style="margin: 5px 0 0 20px;">${imageDims.map((img, idx) => `<li>Image ${idx + 1}: ${img.widthCm} cm × ${img.heightCm} cm</li>`).join('')}</ul></p>` : ''; return imageDims && imageDims.length > 0 ? `<p><strong>Image Dimensions:</strong><ul style="margin: 5px 0 0 20px;">${imageDims.map((img: any, idx: any) => `<li>Image ${idx + 1}: ${img.widthCm} cm × ${img.heightCm} cm</li>`).join('')}</ul></p>` : '';
} catch (e) { } catch (e) {
return ''; return '';
} }
@@ -4,11 +4,12 @@ import { fetchRoyalMailTracking, mapTrackingStatusToOrderStatus } from '@/lib/ro
export async function POST( export async function POST(
req: Request, req: Request,
{ params }: { params: { id: string } } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const { id } = await params;
const order = await prisma.order.findUnique({ const order = await prisma.order.findUnique({
where: { id: params.id }, where: { id: id },
}); });
if (!order) { if (!order) {
@@ -31,13 +32,13 @@ export async function POST(
// Update order with new status // Update order with new status
const updated = await prisma.order.update({ const updated = await prisma.order.update({
where: { id: params.id }, where: { id: id },
data: { data: {
status: newStatus, status: newStatus,
}, },
}); });
console.log(`✓ Order ${params.id} tracking synced: ${trackingData.status}${newStatus}`); console.log(`✓ Order ${id} tracking synced: ${trackingData.status}${newStatus}`);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
+4 -3
View File
@@ -1,14 +1,15 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
export async function POST(_req: Request, { params }: { params: { id: string } }) { export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const proof = await prisma.designProof.findUnique({ where: { id: params.id } }); const { id } = await params;
const proof = await prisma.designProof.findUnique({ where: { id } });
if (!proof) { if (!proof) {
return NextResponse.json({ error: 'Not found' }, { status: 404 }); return NextResponse.json({ error: 'Not found' }, { status: 404 });
} }
await prisma.designProof.update({ await prisma.designProof.update({
where: { id: params.id }, where: { id },
data: { status: 'APPROVED' }, data: { status: 'APPROVED' },
}); });
+8 -6
View File
@@ -2,13 +2,14 @@ import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import type { MessageDTO } from '@/lib/types'; import type { MessageDTO } from '@/lib/types';
export async function GET(_req: Request, { params }: { params: { id: string } }) { export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const rows = await prisma.message.findMany({ const rows = await prisma.message.findMany({
where: { proofId: params.id }, where: { proofId: id },
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
}); });
const messages: MessageDTO[] = rows.map((m) => ({ const messages: MessageDTO[] = rows.map((m: any) => ({
id: m.id, id: m.id,
proofId: m.proofId, proofId: m.proofId,
sender: m.sender as MessageDTO['sender'], sender: m.sender as MessageDTO['sender'],
@@ -19,7 +20,8 @@ export async function GET(_req: Request, { params }: { params: { id: string } })
return NextResponse.json(messages); return NextResponse.json(messages);
} }
export async function POST(req: Request, { params }: { params: { id: string } }) { export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { sender, body } = await req.json(); const { sender, body } = await req.json();
if (sender !== 'ADMIN' && sender !== 'CUSTOMER') { if (sender !== 'ADMIN' && sender !== 'CUSTOMER') {
@@ -30,13 +32,13 @@ export async function POST(req: Request, { params }: { params: { id: string } })
return NextResponse.json({ error: 'Message body is required' }, { status: 400 }); return NextResponse.json({ error: 'Message body is required' }, { status: 400 });
} }
const proof = await prisma.designProof.findUnique({ where: { id: params.id } }); const proof = await prisma.designProof.findUnique({ where: { id } });
if (!proof) { if (!proof) {
return NextResponse.json({ error: 'Not found' }, { status: 404 }); return NextResponse.json({ error: 'Not found' }, { status: 404 });
} }
const created = await prisma.message.create({ const created = await prisma.message.create({
data: { proofId: params.id, sender, body: trimmed }, data: { proofId: id, sender, body: trimmed },
}); });
const message: MessageDTO = { const message: MessageDTO = {
+6 -4
View File
@@ -3,11 +3,12 @@ import { prisma } from '@/lib/prisma';
export async function DELETE( export async function DELETE(
req: Request, req: Request,
{ params }: { params: { id: string } } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const { id } = await params;
const review = await prisma.review.delete({ const review = await prisma.review.delete({
where: { id: params.id }, where: { id: id },
}); });
return NextResponse.json(review); return NextResponse.json(review);
@@ -22,14 +23,15 @@ export async function DELETE(
export async function PATCH( export async function PATCH(
req: Request, req: Request,
{ params }: { params: { id: string } } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const { id } = await params;
const body = await req.json(); const body = await req.json();
const { approved } = body; const { approved } = body;
const review = await prisma.review.update({ const review = await prisma.review.update({
where: { id: params.id }, where: { id: id },
data: { approved }, data: { approved },
}); });
+1 -1
View File
@@ -15,7 +15,7 @@ export async function sendContactMessage(formData: FormData) {
redirect('/contact?error=missing'); redirect('/contact?error=missing');
} }
const ip = getClientIp(headers()); const ip = getClientIp(await headers());
const turnstileToken = String(formData.get('cf-turnstile-response') ?? ''); const turnstileToken = String(formData.get('cf-turnstile-response') ?? '');
const { success } = await verifyTurnstileToken(turnstileToken, ip); const { success } = await verifyTurnstileToken(turnstileToken, ip);
if (!success) { if (!success) {
+1 -1
View File
@@ -20,7 +20,7 @@ export async function createCustomRequest(formData: FormData) {
throw new Error('Missing required fields: product, name, email, and a photo are all required.'); throw new Error('Missing required fields: product, name, email, and a photo are all required.');
} }
const ip = getClientIp(headers()); const ip = getClientIp(await headers());
const turnstileToken = String(formData.get('cf-turnstile-response') ?? ''); const turnstileToken = String(formData.get('cf-turnstile-response') ?? '');
const { success } = await verifyTurnstileToken(turnstileToken, ip); const { success } = await verifyTurnstileToken(turnstileToken, ip);
if (!success) { if (!success) {
+49 -3
View File
@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useCart } from '@/lib/cartStore'; import { useCart } from '@/lib/cartStore';
interface DesignApproval { interface DesignApproval {
@@ -18,18 +19,23 @@ interface DesignApproval {
}; };
designJson: string; designJson: string;
designPreviewUrl: string; designPreviewUrl: string;
designPreviewUrlBack: string | null;
placementPreviewUrl: string | null; placementPreviewUrl: string | null;
placementPreviewUrlBack: string | null;
color: string; color: string;
size: string | null; size: string | null;
status: string; status: string;
expectedDeliveryDate: string | null;
} }
export default function DesignReviewPage({ params }: { params: { id: string } }) { export default function DesignReviewPage({ params }: { params: { id: string } }) {
const router = useRouter();
const [design, setDesign] = useState<DesignApproval | null>(null); const [design, setDesign] = useState<DesignApproval | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [approving, setApproving] = useState(false); const [approving, setApproving] = useState(false);
const [changingMessage, setChangingMessage] = useState(''); const [changingMessage, setChangingMessage] = useState('');
const [sendingChanges, setSendingChanges] = useState(false); const [sendingChanges, setSendingChanges] = useState(false);
const [showCheckoutOptions, setShowCheckoutOptions] = useState(false);
const addItem = useCart((s) => s.addItem); const addItem = useCart((s) => s.addItem);
useEffect(() => { useEffect(() => {
@@ -43,7 +49,11 @@ export default function DesignReviewPage({ params }: { params: { id: string } })
if (!design) return; if (!design) return;
setApproving(true); setApproving(true);
try { try {
const res = await fetch(`/api/designs/${params.id}/approve`, { method: 'POST' }); const res = await fetch(`/api/designs/${params.id}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
if (res.ok) { if (res.ok) {
const updated = await res.json(); const updated = await res.json();
setDesign(updated); setDesign(updated);
@@ -59,7 +69,7 @@ export default function DesignReviewPage({ params }: { params: { id: string } })
size: design.size, size: design.size,
quantity: 1, quantity: 1,
unitPrice: design.product.personalisedPrice ?? design.product.effectivePrice, unitPrice: design.product.personalisedPrice ?? design.product.effectivePrice,
designJson: design.designJson, designJson: JSON.parse(design.designJson),
designPreviewUrl: design.designPreviewUrl, designPreviewUrl: design.designPreviewUrl,
designPreviewUrlBack: design.designPreviewUrlBack, designPreviewUrlBack: design.designPreviewUrlBack,
placementPreviewUrl: design.placementPreviewUrl, placementPreviewUrl: design.placementPreviewUrl,
@@ -68,8 +78,13 @@ export default function DesignReviewPage({ params }: { params: { id: string } })
designHeightCm: null, designHeightCm: null,
}); });
alert('Design approved! Added to your bag.'); setShowCheckoutOptions(true);
} else {
alert('Failed to approve design. Please try again.');
} }
} catch (error) {
console.error('Error approving design:', error);
alert('An error occurred. Please try again.');
} finally { } finally {
setApproving(false); setApproving(false);
} }
@@ -167,6 +182,11 @@ export default function DesignReviewPage({ params }: { params: { id: string } })
<p className="border-t border-line pt-3"> <p className="border-t border-line pt-3">
<strong>Price:</strong> £{((design.product.personalisedPrice ?? design.product.effectivePrice) / 100).toFixed(2)} <strong>Price:</strong> £{((design.product.personalisedPrice ?? design.product.effectivePrice) / 100).toFixed(2)}
</p> </p>
{design.expectedDeliveryDate && (
<p className="text-xs text-muted pt-2">
<strong>Est. delivery:</strong> {new Date(design.expectedDeliveryDate).toLocaleDateString(undefined, { dateStyle: 'medium' })}
</p>
)}
</div> </div>
<button <button
@@ -197,6 +217,32 @@ export default function DesignReviewPage({ params }: { params: { id: string } })
</div> </div>
</div> </div>
</div> </div>
{/* Checkout Options Modal */}
{showCheckoutOptions && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div className="bg-paper border border-line rounded-lg p-8 max-w-md mx-4 shadow-lg">
<h2 className="font-display text-2xl mb-2"> Design approved!</h2>
<p className="text-sm text-muted mb-6">
Your design has been added to your bag. What would you like to do next?
</p>
<div className="space-y-3">
<button
onClick={() => router.push('/checkout')}
className="w-full bg-clay px-4 py-3 text-sm font-medium text-paper hover:bg-clay-dark rounded"
>
Go to Checkout
</button>
<button
onClick={() => setShowCheckoutOptions(false)}
className="w-full border border-ink px-4 py-3 text-sm font-medium text-ink hover:border-clay hover:bg-clay hover:text-paper rounded"
>
Keep Shopping
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }
+3 -2
View File
@@ -2,10 +2,11 @@ import Link from 'next/link';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import Reveal from '@/components/Reveal'; import Reveal from '@/components/Reveal';
export default async function GalleryPage({ searchParams }: { searchParams: { category?: string } }) { export default async function GalleryPage({ searchParams }: { searchParams: Promise<{ category?: string }> }) {
const { category } = await searchParams;
const categories = await prisma.galleryCategory.findMany({ orderBy: { name: 'asc' } }); const categories = await prisma.galleryCategory.findMany({ orderBy: { name: 'asc' } });
const activeSlug = searchParams.category ?? null; const activeSlug = category ?? null;
const activeCategory = activeSlug ? categories.find((c) => c.slug === activeSlug) ?? null : null; const activeCategory = activeSlug ? categories.find((c) => c.slug === activeSlug) ?? null : null;
const photos = await prisma.galleryPhoto.findMany({ const photos = await prisma.galleryPhoto.findMany({
+3 -14
View File
@@ -7,6 +7,7 @@ import Footer from '@/components/Footer';
import PromotionBanner from '@/components/PromotionBanner'; import PromotionBanner from '@/components/PromotionBanner';
import MaintenancePage from '@/components/MaintenancePage'; import MaintenancePage from '@/components/MaintenancePage';
import LogoutOnUnload from '@/components/LogoutOnUnload'; import LogoutOnUnload from '@/components/LogoutOnUnload';
import ThemeInitializer from '@/components/ThemeInitializer';
import { CurrencyProvider } from '@/lib/CurrencyContext'; import { CurrencyProvider } from '@/lib/CurrencyContext';
import { getSiteSettings } from '@/lib/settings'; import { getSiteSettings } from '@/lib/settings';
import { isAdminAuthenticated } from '@/lib/adminAuth'; import { isAdminAuthenticated } from '@/lib/adminAuth';
@@ -31,7 +32,7 @@ export const metadata: Metadata = {
export default async function RootLayout({ children }: { children: React.ReactNode }) { export default async function RootLayout({ children }: { children: React.ReactNode }) {
const { maintenanceMode, maintenanceMessage } = await getSiteSettings(); const { maintenanceMode, maintenanceMessage } = await getSiteSettings();
const pathname = headers().get('x-pathname') ?? ''; const pathname = (await headers()).get('x-pathname') ?? '';
// Admins bypass maintenance (they can keep working); /admin/* is always // Admins bypass maintenance (they can keep working); /admin/* is always
// reachable so the owner can log in and toggle it back off. // reachable so the owner can log in and toggle it back off.
// Also allow /checkout/* pages so customers can complete purchases and see results. // Also allow /checkout/* pages so customers can complete purchases and see results.
@@ -50,21 +51,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600&family=Oswald:wght@500&family=Bebas+Neue&family=Pacifico&family=Permanent+Marker&family=Caveat:wght@600&display=swap" href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600&family=Oswald:wght@500&family=Bebas+Neue&family=Pacifico&family=Permanent+Marker&family=Caveat:wght@600&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
{/* Runs before paint so there's no flash of the wrong theme on load. */}
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
var stored = localStorage.getItem('theme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var isDark = stored ? stored === 'dark' : prefersDark;
if (isDark) document.documentElement.classList.add('dark');
})();
`,
}}
/>
</head> </head>
<body suppressHydrationWarning className="flex min-h-screen flex-col"> <body suppressHydrationWarning className="flex min-h-screen flex-col">
<ThemeInitializer />
<LogoutOnUnload /> <LogoutOnUnload />
<CurrencyProvider> <CurrencyProvider>
{showMaintenance ? ( {showMaintenance ? (
+9 -9
View File
@@ -1,22 +1,22 @@
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import dynamic from 'next/dynamic';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { toProductDTO } from '@/lib/product'; import { toProductDTO } from '@/lib/product';
import { fetchActivePromotions } from '@/lib/promotions'; import { fetchActivePromotions } from '@/lib/promotions';
import ProductCard from '@/components/ProductCard'; import ProductCard from '@/components/ProductCard';
import DesignCanvasWrapper from '@/components/DesignCanvasWrapper';
const DesignCanvas = dynamic(() => import('@/components/DesignCanvas'), { ssr: false });
export default async function ProductDetailPage({ export default async function ProductDetailPage({
params, params,
searchParams, searchParams,
}: { }: {
params: { slug: string }; params: Promise<{ slug: string }>;
searchParams: { customRequestId?: string }; searchParams: Promise<{ customRequestId?: string }>;
}) { }) {
const { slug } = await params;
const { customRequestId } = await searchParams;
const activePromotions = await fetchActivePromotions(); const activePromotions = await fetchActivePromotions();
const row = await prisma.product.findUnique({ where: { slug: params.slug } }); const row = await prisma.product.findUnique({ where: { slug } });
if (!row || !row.showOnPersonalised) notFound(); if (!row || !row.showOnPersonalised) notFound();
const product = toProductDTO(row, activePromotions, 'personalised'); const product = toProductDTO(row, activePromotions, 'personalised');
@@ -29,9 +29,9 @@ export default async function ProductDetailPage({
// Fetch custom photo from request if customRequestId is provided // Fetch custom photo from request if customRequestId is provided
let customPhoto: string | undefined; let customPhoto: string | undefined;
if (searchParams.customRequestId) { if (customRequestId) {
const customRequest = await prisma.customRequest.findUnique({ const customRequest = await prisma.customRequest.findUnique({
where: { id: searchParams.customRequestId }, where: { id: customRequestId },
}); });
if (customRequest) { if (customRequest) {
customPhoto = customRequest.imageDataUrl; customPhoto = customRequest.imageDataUrl;
@@ -40,7 +40,7 @@ export default async function ProductDetailPage({
return ( return (
<div className="mx-auto max-w-6xl px-6 py-12"> <div className="mx-auto max-w-6xl px-6 py-12">
<DesignCanvas product={product} customPhoto={customPhoto} /> <DesignCanvasWrapper product={product} customPhoto={customPhoto} />
{related.length > 0 && ( {related.length > 0 && (
<section className="mt-20 border-t border-line pt-12"> <section className="mt-20 border-t border-line pt-12">
+38 -257
View File
@@ -1,300 +1,81 @@
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { toProductDTO } from '@/lib/product'; import { toProductDTO } from '@/lib/product';
import { fetchActivePromotions } from '@/lib/promotions'; import { fetchActivePromotions } from '@/lib/promotions';
import { getPersonalisedPaletteColors } from '@/lib/cache';
import ProductCard from '@/components/ProductCard'; import ProductCard from '@/components/ProductCard';
import type { Prisma } from '@prisma/client';
type SearchParams = { type SearchParams = {
category?: string | string[];
collection?: string | string[];
color?: string | string[];
min?: string;
max?: string;
q?: string;
page?: string; page?: string;
}; };
const PRODUCTS_PER_PAGE = 20; const PRODUCTS_PER_PAGE = 20;
function toArray(v?: string | string[]) { export default async function ProductsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
if (!v) return []; const { page } = await searchParams;
return Array.isArray(v) ? v : [v]; const currentPage = Math.max(1, Number(page) || 1);
}
export default async function ProductsPage({ searchParams }: { searchParams: SearchParams }) {
const categoryRows = await prisma.category.findMany({
where: { showOnPersonalised: true, parentId: null },
orderBy: { name: 'asc' },
select: {
id: true,
name: true,
slug: true,
children: {
where: { showOnPersonalised: true },
select: { id: true, name: true, slug: true },
orderBy: { name: 'asc' },
},
},
});
// Build flat list of all categories (parents and children) for filtering
const allCategories = categoryRows.flatMap((parent) => [
{ value: parent.slug, label: parent.name },
...parent.children.map((child) => ({ value: child.slug, label: `${parent.name} > ${child.name}` })),
]);
const CATEGORIES = allCategories;
const collectionRows = await prisma.collection.findMany({
select: { slug: true, name: true, group: true },
orderBy: { name: 'asc' },
});
const OCCASIONS = collectionRows.filter((c) => c.group === 'OCCASION').map((c) => ({ value: c.slug, label: c.name }));
const RECIPIENTS = collectionRows.filter((c) => c.group === 'RECIPIENT').map((c) => ({ value: c.slug, label: c.name }));
const selectedCategories = toArray(searchParams.category);
const selectedCollections = toArray(searchParams.collection);
const selectedColors = toArray(searchParams.color);
const q = searchParams.q?.trim() ?? '';
const minDollars = searchParams.min ? Number(searchParams.min) : undefined;
const maxDollars = searchParams.max ? Number(searchParams.max) : undefined;
const currentPage = Math.max(1, Number(searchParams.page) || 1);
// Cached palette across every personalised product
const palette = await getPersonalisedPaletteColors();
const where: Prisma.ProductWhereInput = { showOnPersonalised: true };
if (selectedCategories.length) {
where.category = { in: selectedCategories };
}
if (selectedCollections.length) {
where.collections = { some: { slug: { in: selectedCollections } } };
}
if (selectedColors.length) {
where.AND = [{ OR: selectedColors.map((c) => ({ colors: { contains: c } })) }];
}
if (q) {
where.name = { contains: q };
}
if (minDollars !== undefined || maxDollars !== undefined) {
where.basePrice = {
...(minDollars !== undefined ? { gte: Math.round(minDollars * 100) } : {}),
...(maxDollars !== undefined ? { lte: Math.round(maxDollars * 100) } : {}),
};
}
const activePromotions = await fetchActivePromotions(); const activePromotions = await fetchActivePromotions();
const [rows, totalCount] = await Promise.all([ const [rows, totalCount] = await Promise.all([
prisma.product.findMany({ prisma.product.findMany({
where, where: { showOnPersonalised: true },
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
take: PRODUCTS_PER_PAGE, take: PRODUCTS_PER_PAGE,
skip: (currentPage - 1) * PRODUCTS_PER_PAGE, skip: (currentPage - 1) * PRODUCTS_PER_PAGE,
}), }),
prisma.product.count({ where }), prisma.product.count({ where: { showOnPersonalised: true } }),
]); ]);
const products = rows.map((p) => toProductDTO(p, activePromotions, 'personalised')); const products = rows.map((p) => toProductDTO(p, activePromotions, 'personalised'));
const totalPages = Math.ceil(totalCount / PRODUCTS_PER_PAGE); const totalPages = Math.ceil(totalCount / PRODUCTS_PER_PAGE);
const hasFilters =
selectedCategories.length > 0 ||
selectedCollections.length > 0 ||
selectedColors.length > 0 ||
q ||
minDollars !== undefined ||
maxDollars !== undefined;
return ( return (
<div className="mx-auto max-w-6xl px-6 py-12"> <div className="mx-auto max-w-6xl px-6 py-12">
<div className="mb-8 flex items-baseline justify-between"> <div className="mb-12 flex items-baseline justify-between">
<h1 className="font-display text-4xl">Shop all</h1> <h1 className="font-display text-4xl">Shop all</h1>
<p className="tag-label"> <p className="tag-label">
{totalCount} template{totalCount === 1 ? '' : 's'} {totalCount} template{totalCount === 1 ? '' : 's'}
</p> </p>
</div> </div>
<form method="GET" className="grid gap-10 md:grid-cols-[220px_1fr]"> <div>
{/* Sidebar filters */} {products.length === 0 ? (
<aside className="space-y-8"> <div className="border border-line bg-surface p-12 text-center">
<div> <p className="font-display text-xl">Nothing here yet</p>
<label htmlFor="q" className="tag-label mb-2 block"> <p className="mt-2 text-sm text-muted">Templates will show up here once created.</p>
Search
</label>
<input
id="q"
name="q"
defaultValue={q}
placeholder="Search templates…"
className="w-full border border-line px-3 py-2 text-sm focus:border-ink"
/>
</div> </div>
) : (
<div> <div>
<p className="tag-label mb-3">Category</p> <div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-2"> {products.map((p) => (
{CATEGORIES.map((c) => ( <ProductCard key={p.id} product={p} />
<label key={c.value} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="category"
value={c.value}
defaultChecked={selectedCategories.includes(c.value)}
className="h-4 w-4 accent-splash-pink"
/>
{c.label}
</label>
))} ))}
</div> </div>
</div>
{OCCASIONS.length > 0 && ( {totalPages > 1 && (
<div> <div className="mt-12 flex items-center justify-center gap-4 border-t border-line pt-8">
<p className="tag-label mb-3">Occasion</p> {currentPage > 1 && (
<div className="space-y-2"> <a
{OCCASIONS.map((c) => ( href={`/made-to-order?page=${currentPage - 1}`}
<label key={c.value} className="flex items-center gap-2 text-sm"> className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
<input >
type="checkbox" Previous
name="collection" </a>
value={c.value} )}
defaultChecked={selectedCollections.includes(c.value)} <p className="text-sm text-muted">
className="h-4 w-4 accent-splash-pink" Page {currentPage} of {totalPages}
/> </p>
{c.label} {currentPage < totalPages && (
</label> <a
))} href={`/made-to-order?page=${currentPage + 1}`}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
Next
</a>
)}
</div> </div>
</div>
)}
{RECIPIENTS.length > 0 && (
<div>
<p className="tag-label mb-3">Gifts for</p>
<div className="space-y-2">
{RECIPIENTS.map((c) => (
<label key={c.value} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="collection"
value={c.value}
defaultChecked={selectedCollections.includes(c.value)}
className="h-4 w-4 accent-splash-pink"
/>
{c.label}
</label>
))}
</div>
</div>
)}
<div>
<p className="tag-label mb-3">Color</p>
<div className="flex flex-wrap gap-2">
{palette.map((hex) => {
const checked = selectedColors.includes(hex);
return (
<label key={hex} className="cursor-pointer">
<input
type="checkbox"
name="color"
value={hex}
defaultChecked={checked}
className="peer sr-only"
/>
<span
title={hex}
className={`block h-8 w-8 rounded-full border transition-transform peer-checked:scale-110 peer-checked:border-ink peer-checked:ring-2 peer-checked:ring-ink peer-checked:ring-offset-2 peer-checked:ring-offset-paper ${
checked ? 'border-ink' : 'border-line'
}`}
style={{ backgroundColor: hex }}
/>
</label>
);
})}
</div>
</div>
<div>
<p className="tag-label mb-3">Price</p>
<div className="flex items-center gap-2">
<input
type="number"
name="min"
min={0}
placeholder="Min"
defaultValue={searchParams.min ?? ''}
className="w-full border border-line px-2 py-2 text-sm focus:border-ink"
/>
<span className="text-muted"></span>
<input
type="number"
name="max"
min={0}
placeholder="Max"
defaultValue={searchParams.max ?? ''}
className="w-full border border-line px-2 py-2 text-sm focus:border-ink"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<button
type="submit"
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
Apply filters
</button>
{hasFilters && (
<a href="/made-to-order" className="text-center text-sm text-muted hover:text-ink">
Clear filters
</a>
)} )}
</div> </div>
</aside> )}
</div>
{/* Grid */}
<div>
{products.length === 0 ? (
<div className="border border-line bg-surface p-12 text-center">
<p className="font-display text-xl">No templates match those filters</p>
<p className="mt-2 text-sm text-muted">Try widening your price range or clearing a filter.</p>
</div>
) : (
<div>
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
{products.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</div>
{totalPages > 1 && (
<div className="mt-12 flex items-center justify-center gap-4 border-t border-line pt-8">
{currentPage > 1 && (
<a
href={`/made-to-order?page=${currentPage - 1}${selectedCategories.length ? `&category=${selectedCategories.join('&category=')}` : ''}${selectedCollections.length ? `&collection=${selectedCollections.join('&collection=')}` : ''}${selectedColors.length ? `&color=${selectedColors.join('&color=')}` : ''}${q ? `&q=${encodeURIComponent(q)}` : ''}${minDollars !== undefined ? `&min=${minDollars}` : ''}${maxDollars !== undefined ? `&max=${maxDollars}` : ''}`}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
Previous
</a>
)}
<p className="text-sm text-muted">
Page {currentPage} of {totalPages}
</p>
{currentPage < totalPages && (
<a
href={`/made-to-order?page=${currentPage + 1}${selectedCategories.length ? `&category=${selectedCategories.join('&category=')}` : ''}${selectedCollections.length ? `&collection=${selectedCollections.join('&collection=')}` : ''}${selectedColors.length ? `&color=${selectedColors.join('&color=')}` : ''}${q ? `&q=${encodeURIComponent(q)}` : ''}${minDollars !== undefined ? `&min=${minDollars}` : ''}${maxDollars !== undefined ? `&max=${maxDollars}` : ''}`}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
Next
</a>
)}
</div>
)}
</div>
)}
</div>
</form>
</div> </div>
); );
} }
+32 -261
View File
@@ -1,120 +1,41 @@
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { toProductDTO } from '@/lib/product'; import { toProductDTO } from '@/lib/product';
import { fetchActivePromotions } from '@/lib/promotions'; import { fetchActivePromotions } from '@/lib/promotions';
import { getPaletteColors } from '@/lib/cache';
import ProductCard from '@/components/ProductCard'; import ProductCard from '@/components/ProductCard';
import type { Prisma } from '@prisma/client';
type SearchParams = { type SearchParams = {
category?: string | string[];
collection?: string | string[];
color?: string | string[];
min?: string;
max?: string;
q?: string;
page?: string; page?: string;
}; };
const PRODUCTS_PER_PAGE = 20; const PRODUCTS_PER_PAGE = 20;
function toArray(v?: string | string[]) {
if (!v) return [];
return Array.isArray(v) ? v : [v];
}
export default async function ReadyMadeProductsPage({ searchParams }: { searchParams: SearchParams }) { export default async function ReadyMadeProductsPage({ searchParams }: { searchParams: SearchParams }) {
const categoryRows = await prisma.category.findMany({
where: { showOnProducts: true, parentId: null },
orderBy: { name: 'asc' },
select: {
id: true,
name: true,
slug: true,
children: {
where: { showOnProducts: true },
select: { id: true, name: true, slug: true },
orderBy: { name: 'asc' },
},
},
});
// Build flat list of all categories (parents and children) for filtering
const allCategories = categoryRows.flatMap((parent) => [
{ value: parent.slug, label: parent.name },
...parent.children.map((child) => ({ value: child.slug, label: `${parent.name} > ${child.name}` })),
]);
const CATEGORIES = allCategories;
const collectionRows = await prisma.collection.findMany({
select: { slug: true, name: true, group: true },
orderBy: { name: 'asc' },
});
const OCCASIONS = collectionRows.filter((c) => c.group === 'OCCASION').map((c) => ({ value: c.slug, label: c.name }));
const RECIPIENTS = collectionRows.filter((c) => c.group === 'RECIPIENT').map((c) => ({ value: c.slug, label: c.name }));
const selectedCategories = toArray(searchParams.category);
const selectedCollections = toArray(searchParams.collection);
const selectedColors = toArray(searchParams.color);
const q = searchParams.q?.trim() ?? '';
const minDollars = searchParams.min ? Number(searchParams.min) : undefined;
const maxDollars = searchParams.max ? Number(searchParams.max) : undefined;
const currentPage = Math.max(1, Number(searchParams.page) || 1); const currentPage = Math.max(1, Number(searchParams.page) || 1);
// Cached palette across ready-made products for the swatch filter
const palette = await getPaletteColors();
const where: Prisma.ProductWhereInput = { showAsReadyMade: true };
if (selectedCategories.length) {
where.category = { in: selectedCategories };
}
if (selectedCollections.length) {
where.collections = { some: { slug: { in: selectedCollections } } };
}
if (selectedColors.length) {
where.AND = [{ OR: selectedColors.map((c) => ({ colors: { contains: c } })) }];
}
if (q) {
where.name = { contains: q };
}
if (minDollars !== undefined || maxDollars !== undefined) {
where.basePrice = {
...(minDollars !== undefined ? { gte: Math.round(minDollars * 100) } : {}),
...(maxDollars !== undefined ? { lte: Math.round(maxDollars * 100) } : {}),
};
}
const activePromotions = await fetchActivePromotions(); const activePromotions = await fetchActivePromotions();
const [rows, totalCount] = await Promise.all([ const [rows, totalCount] = await Promise.all([
prisma.product.findMany({ prisma.product.findMany({
where, where: { showAsReadyMade: true },
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
take: PRODUCTS_PER_PAGE, take: PRODUCTS_PER_PAGE,
skip: (currentPage - 1) * PRODUCTS_PER_PAGE, skip: (currentPage - 1) * PRODUCTS_PER_PAGE,
}), }),
prisma.product.count({ where }), prisma.product.count({ where: { showAsReadyMade: true } }),
]); ]);
const products = rows.map((p) => toProductDTO(p, activePromotions, 'plain')); const products = rows.map((p) => toProductDTO(p, activePromotions, 'plain'));
const totalPages = Math.ceil(totalCount / PRODUCTS_PER_PAGE); const totalPages = Math.ceil(totalCount / PRODUCTS_PER_PAGE);
const hasFilters =
selectedCategories.length > 0 ||
selectedCollections.length > 0 ||
selectedColors.length > 0 ||
q ||
minDollars !== undefined ||
maxDollars !== undefined;
return ( return (
<div className="mx-auto max-w-6xl px-6 py-12"> <div className="mx-auto max-w-6xl px-6 py-12">
<div className="mb-8 flex items-baseline justify-between"> <div className="mb-12 flex items-baseline justify-between">
<h1 className="font-display text-4xl">Products</h1> <h1 className="font-display text-4xl">Products</h1>
<p className="tag-label"> <p className="tag-label">
{products.length} item{products.length === 1 ? '' : 's'} {totalCount} item{totalCount === 1 ? '' : 's'}
</p> </p>
</div> </div>
{products.length === 0 && !hasFilters ? ( {products.length === 0 ? (
<div className="border border-line bg-surface p-12 text-center"> <div className="border border-line bg-surface p-12 text-center">
<p className="font-display text-xl">Nothing here yet</p> <p className="font-display text-xl">Nothing here yet</p>
<p className="mt-2 text-sm text-muted"> <p className="mt-2 text-sm text-muted">
@@ -123,187 +44,37 @@ export default async function ReadyMadeProductsPage({ searchParams }: { searchPa
</p> </p>
</div> </div>
) : ( ) : (
<form method="GET" className="grid gap-10 md:grid-cols-[220px_1fr]"> <div>
{/* Sidebar filters */} <div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
<aside className="space-y-8"> {products.map((p) => (
<div> <ProductCard key={p.id} product={p} hrefBase="/products" />
<label htmlFor="q" className="tag-label mb-2 block"> ))}
Search </div>
</label>
<input
id="q"
name="q"
defaultValue={q}
placeholder="Search products…"
className="w-full border border-line px-3 py-2 text-sm focus:border-ink"
/>
</div>
<div> {totalPages > 1 && (
<p className="tag-label mb-3">Category</p> <div className="mt-12 flex items-center justify-center gap-4 border-t border-line pt-8">
<div className="space-y-2"> {currentPage > 1 && (
{CATEGORIES.map((c) => ( <a
<label key={c.value} className="flex items-center gap-2 text-sm"> href={`/products?page=${currentPage - 1}`}
<input className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
type="checkbox" >
name="category" Previous
value={c.value} </a>
defaultChecked={selectedCategories.includes(c.value)} )}
className="h-4 w-4 accent-splash-pink" <p className="text-sm text-muted">
/> Page {currentPage} of {totalPages}
{c.label} </p>
</label> {currentPage < totalPages && (
))} <a
</div> href={`/products?page=${currentPage + 1}`}
</div> className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
{OCCASIONS.length > 0 && ( Next
<div>
<p className="tag-label mb-3">Occasion</p>
<div className="space-y-2">
{OCCASIONS.map((c) => (
<label key={c.value} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="collection"
value={c.value}
defaultChecked={selectedCollections.includes(c.value)}
className="h-4 w-4 accent-splash-pink"
/>
{c.label}
</label>
))}
</div>
</div>
)}
{RECIPIENTS.length > 0 && (
<div>
<p className="tag-label mb-3">Gifts for</p>
<div className="space-y-2">
{RECIPIENTS.map((c) => (
<label key={c.value} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="collection"
value={c.value}
defaultChecked={selectedCollections.includes(c.value)}
className="h-4 w-4 accent-splash-pink"
/>
{c.label}
</label>
))}
</div>
</div>
)}
<div>
<p className="tag-label mb-3">Color</p>
<div className="flex flex-wrap gap-2">
{palette.map((hex) => {
const checked = selectedColors.includes(hex);
return (
<label key={hex} className="cursor-pointer">
<input
type="checkbox"
name="color"
value={hex}
defaultChecked={checked}
className="peer sr-only"
/>
<span
title={hex}
className={`block h-8 w-8 rounded-full border transition-transform peer-checked:scale-110 peer-checked:border-ink peer-checked:ring-2 peer-checked:ring-ink peer-checked:ring-offset-2 peer-checked:ring-offset-paper ${
checked ? 'border-ink' : 'border-line'
}`}
style={{ backgroundColor: hex }}
/>
</label>
);
})}
</div>
</div>
<div>
<p className="tag-label mb-3">Price</p>
<div className="flex items-center gap-2">
<input
type="number"
name="min"
min={0}
placeholder="Min"
defaultValue={searchParams.min ?? ''}
className="w-full border border-line px-2 py-2 text-sm focus:border-ink"
/>
<span className="text-muted"></span>
<input
type="number"
name="max"
min={0}
placeholder="Max"
defaultValue={searchParams.max ?? ''}
className="w-full border border-line px-2 py-2 text-sm focus:border-ink"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<button
type="submit"
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
Apply filters
</button>
{hasFilters && (
<a href="/products" className="text-center text-sm text-muted hover:text-ink">
Clear filters
</a> </a>
)} )}
</div> </div>
</aside> )}
</div>
{/* Grid */}
<div>
{products.length === 0 ? (
<div className="border border-line bg-surface p-12 text-center">
<p className="font-display text-xl">No products match those filters</p>
<p className="mt-2 text-sm text-muted">Try widening your price range or clearing a filter.</p>
</div>
) : (
<div>
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
{products.map((p) => (
<ProductCard key={p.id} product={p} hrefBase="/products" />
))}
</div>
{totalPages > 1 && (
<div className="mt-12 flex items-center justify-center gap-4 border-t border-line pt-8">
{currentPage > 1 && (
<a
href={`/products?page=${currentPage - 1}${selectedCategories.length ? `&category=${selectedCategories.join('&category=')}` : ''}${selectedCollections.length ? `&collection=${selectedCollections.join('&collection=')}` : ''}${selectedColors.length ? `&color=${selectedColors.join('&color=')}` : ''}${q ? `&q=${encodeURIComponent(q)}` : ''}${minDollars !== undefined ? `&min=${minDollars}` : ''}${maxDollars !== undefined ? `&max=${maxDollars}` : ''}`}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
Previous
</a>
)}
<p className="text-sm text-muted">
Page {currentPage} of {totalPages}
</p>
{currentPage < totalPages && (
<a
href={`/products?page=${currentPage + 1}${selectedCategories.length ? `&category=${selectedCategories.join('&category=')}` : ''}${selectedCollections.length ? `&collection=${selectedCollections.join('&collection=')}` : ''}${selectedColors.length ? `&color=${selectedColors.join('&color=')}` : ''}${q ? `&q=${encodeURIComponent(q)}` : ''}${minDollars !== undefined ? `&min=${minDollars}` : ''}${maxDollars !== undefined ? `&max=${maxDollars}` : ''}`}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
Next
</a>
)}
</div>
)}
</div>
)}
</div>
</form>
)} )}
</div> </div>
); );
+1 -1
View File
@@ -2,7 +2,7 @@
import { useRef, useState, useEffect } from 'react'; import { useRef, useState, useEffect } from 'react';
import { Stage, Layer, Text, Image as KonvaImage, Transformer } from 'react-konva'; import { Stage, Layer, Text, Image as KonvaImage, Transformer } from 'react-konva';
import type Konva from 'konva'; import Konva from 'konva';
import type { DesignElement } from '@/lib/types'; import type { DesignElement } from '@/lib/types';
interface AdminDesignEditorProps { interface AdminDesignEditorProps {
+3 -1
View File
@@ -26,11 +26,13 @@ export default function ApproveSection({ proof }: { proof: ProofDTO }) {
size: null, size: null,
quantity: proof.quantity, quantity: proof.quantity,
unitPrice: proof.unitPrice, unitPrice: proof.unitPrice,
designJson: { front: [], back: [] }, designJson: { front: [], back: [] } as any,
designPreviewUrl: proof.imageDataUrl, designPreviewUrl: proof.imageDataUrl,
designPreviewUrlBack: null, designPreviewUrlBack: null,
placementPreviewUrl: proof.imageDataUrl, placementPreviewUrl: proof.imageDataUrl,
placementPreviewUrlBack: null, placementPreviewUrlBack: null,
designWidthCm: null,
designHeightCm: null,
}); });
setStatus('APPROVED'); setStatus('APPROVED');
router.push('/cart'); router.push('/cart');
+24 -16
View File
@@ -325,12 +325,13 @@ function PrintSurface({
/> />
{elements.map((el) => {elements.map((el) =>
el.type === 'text' ? ( el.type === 'text' ? (
el.curvedPath ? ( (el as any).curvedPath ? (
// Curved text - TextPath with dragging via offset // Curved text - TextPath with dragging via offset
(() => { (() => {
const elAny = el as any;
const offsetX = el.x - 280; const offsetX = el.x - 280;
const offsetY = el.y - 280; const offsetY = el.y - 280;
const pathData = generateCurvePath(el.curvedPath.type, el.curvedPath.radius, el.curvedPath.reverse, offsetX, offsetY); const pathData = generateCurvePath(elAny.curvedPath.type, elAny.curvedPath.radius, elAny.curvedPath.reverse, offsetX, offsetY);
console.log(`🔄 Rendering curved text ${el.text}: position (${el.x}, ${el.y}), offset (${offsetX}, ${offsetY})`); console.log(`🔄 Rendering curved text ${el.text}: position (${el.x}, ${el.y}), offset (${offsetX}, ${offsetY})`);
return ( return (
<TextPath <TextPath
@@ -773,10 +774,10 @@ export default function DesignCanvas({
salePrice: product.onSale ? product.effectivePrice : null, salePrice: product.onSale ? product.effectivePrice : null,
saleStartsAt: null, saleStartsAt: null,
saleEndsAt: null, saleEndsAt: null,
}, } as any,
[], [],
new Date(), new Date(),
true, 'personalised',
).price ).price
: product.effectivePrice; : product.effectivePrice;
@@ -1251,10 +1252,10 @@ export default function DesignCanvas({
salePrice: product.onSale ? product.effectivePrice : null, salePrice: product.onSale ? product.effectivePrice : null,
saleStartsAt: null, saleStartsAt: null,
saleEndsAt: null, saleEndsAt: null,
}, } as any,
[], [],
new Date(), new Date(),
true, 'personalised',
); );
return ( return (
<> <>
@@ -1493,20 +1494,24 @@ export default function DesignCanvas({
<label className="flex items-center gap-2 text-sm text-muted mb-3"> <label className="flex items-center gap-2 text-sm text-muted mb-3">
<input <input
type="checkbox" type="checkbox"
checked={text.curvedPath ? true : false} checked={(text as any).curvedPath ? true : false}
onChange={(e) => updateElement(text.id, { curvedPath: e.target.checked ? { type: 'circle', radius: 80 } : null })} onChange={(e) => updateElement(text.id, { curvedPath: e.target.checked ? { type: 'circle', radius: 80 } : null } as any)}
className="cursor-pointer" className="cursor-pointer"
/> />
Curved text Curved text
</label> </label>
{text.curvedPath && ( {(text as any).curvedPath && (
<div className="space-y-3"> <div className="space-y-3">
{(() => {
const textAny = text as any;
return (
<>
<label className="flex flex-col gap-1"> <label className="flex flex-col gap-1">
<span className="text-xs text-muted">Curve type</span> <span className="text-xs text-muted">Curve type</span>
<select <select
value={text.curvedPath.type || 'circle'} value={textAny.curvedPath.type || 'circle'}
onChange={(e) => updateElement(text.id, { curvedPath: { ...text.curvedPath, type: e.target.value as any } })} onChange={(e) => updateElement(text.id, { curvedPath: { ...textAny.curvedPath, type: e.target.value as any } } as any)}
className="border border-line bg-paper px-2 py-1 text-xs" className="border border-line bg-paper px-2 py-1 text-xs"
> >
<option value="circle">Circle</option> <option value="circle">Circle</option>
@@ -1523,23 +1528,26 @@ export default function DesignCanvas({
type="range" type="range"
min={20} min={20}
max={200} max={200}
value={text.curvedPath.radius || 80} value={textAny.curvedPath.radius || 80}
onChange={(e) => updateElement(text.id, { curvedPath: { ...text.curvedPath, radius: Number(e.target.value) } })} onChange={(e) => updateElement(text.id, { curvedPath: { ...textAny.curvedPath, radius: Number(e.target.value) } } as any)}
className="flex-1" className="flex-1"
/> />
<span className="w-12 text-right font-mono text-xs">{text.curvedPath.radius || 80}</span> <span className="w-12 text-right font-mono text-xs">{textAny.curvedPath.radius || 80}</span>
</div> </div>
</label> </label>
<label className="flex items-center gap-2 text-xs"> <label className="flex items-center gap-2 text-xs">
<input <input
type="checkbox" type="checkbox"
checked={text.curvedPath.reverse ? true : false} checked={textAny.curvedPath.reverse ? true : false}
onChange={(e) => updateElement(text.id, { curvedPath: { ...text.curvedPath, reverse: e.target.checked } })} onChange={(e) => updateElement(text.id, { curvedPath: { ...textAny.curvedPath, reverse: e.target.checked } } as any)}
className="cursor-pointer" className="cursor-pointer"
/> />
<span className="text-muted">Reverse direction</span> <span className="text-muted">Reverse direction</span>
</label> </label>
</>
);
})()}
</div> </div>
)} )}
</div> </div>
@@ -0,0 +1,39 @@
'use client';
import React, { ReactNode } from 'react';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
}
class DesignCanvasErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return (
<div className="flex h-96 items-center justify-center rounded-lg border border-line bg-paper">
<div className="text-center">
<p className="mb-2 font-medium text-ink">Design Canvas Loading...</p>
<p className="text-sm text-muted">Please refresh the page if this persists</p>
</div>
</div>
);
}
return this.props.children;
}
}
export default DesignCanvasErrorBoundary;
+13
View File
@@ -0,0 +1,13 @@
'use client';
import FabricDesignCanvasV2 from './FabricDesignCanvasV2';
import type { ProductDTO } from '@/lib/types';
interface DesignCanvasWrapperProps {
product: ProductDTO;
customPhoto?: string;
}
export default function DesignCanvasWrapper({ product, customPhoto }: DesignCanvasWrapperProps) {
return <FabricDesignCanvasV2 product={product} customPhoto={customPhoto} />;
}
+881
View File
@@ -0,0 +1,881 @@
'use client';
import { useRef, useState, useCallback, useEffect } from 'react';
import { v4 as uuid } from 'uuid';
import ProductMockup from './ProductMockup';
import Price from './Price';
import LoginPromptModal from './LoginPromptModal';
import { useCart } from '@/lib/cartStore';
import { getEffectivePrice } from '@/lib/pricing';
import { checkCustomerAuth } from '@/app/actions';
import type { DesignElement, ProductDTO } from '@/lib/types';
const DISPLAY = 560;
const FONTS = ['Arial', 'Georgia', 'Courier New', 'Times New Roman', 'Verdana', 'Comic Sans MS'];
interface DesignCanvasProps {
product: ProductDTO;
customPhoto?: string;
}
export default function FabricDesignCanvas({ product, customPhoto }: DesignCanvasProps) {
const canvasContainerRef = useRef<HTMLDivElement>(null);
const frontCanvasRef = useRef<HTMLCanvasElement>(null);
const backCanvasRef = useRef<HTMLCanvasElement>(null);
const measureCanvasRef = useRef<HTMLCanvasElement | null>(null);
const [design, setDesign] = useState<{ front: DesignElement[]; back: DesignElement[] }>({
front: [],
back: [],
});
const [side, setSide] = useState<'front' | 'back'>('front');
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [pendingId, setPendingId] = useState<string | null>(null);
const [selectedColor, setSelectedColor] = useState<string>(product.colors[0]);
const [dragging, setDragging] = useState<{
id: string;
type: 'move' | 'resize-nw' | 'resize-ne' | 'resize-sw' | 'resize-se';
startX: number;
startY: number;
elementX: number;
elementY: number;
offsetX: number;
offsetY: number;
} | null>(null);
const { addItem } = useCart();
// Helper to measure text width
const measureTextWidth = (text: string, fontSize: number, fontFamily: string): number => {
if (!measureCanvasRef.current) {
measureCanvasRef.current = document.createElement('canvas');
}
const ctx = measureCanvasRef.current.getContext('2d');
if (!ctx) return text.length * (fontSize * 0.6); // Fallback estimate
ctx.font = `${fontSize}px ${fontFamily}`;
return ctx.measureText(text).width;
};
// Validate element stays within design area
const validateElementBounds = useCallback((element: DesignElement, printArea: any): DesignElement => {
const minX = Math.round(printArea.xPct * DISPLAY);
const minY = Math.round(printArea.yPct * DISPLAY);
const maxX = minX + Math.round(printArea.wPct * DISPLAY);
const maxY = minY + Math.round(printArea.hPct * DISPLAY);
const elementWidth = element.type === 'text' ? 150 : element.width;
const elementHeight = element.type === 'text' ? element.fontSize : element.height;
return {
...element,
x: Math.max(minX, Math.min(element.x, maxX - elementWidth)),
y: Math.max(minY, Math.min(element.y, maxY - elementHeight)),
};
}, []);
// Reset design state when product changes
useEffect(() => {
setDesign({ front: [], back: [] });
setSide('front');
setSelectedId(null);
setPendingId(null);
}, [product.id]);
// Clear selection and pending when switching views
useEffect(() => {
setSelectedId(null);
setPendingId(null);
}, [side]);
// Redraw canvas whenever design, selection, or dragging changes (for dynamic updates)
useEffect(() => {
const canvas = side === 'front' ? frontCanvasRef.current : backCanvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Get print area and validate all elements before drawing
const printArea = side === 'front' ? product.printArea : product.printAreaBack || product.printArea;
const elements = side === 'front' ? design.front : design.back;
elements.forEach((el) => {
// Validate element bounds before drawing
const validatedEl = validateElementBounds(el, printArea);
if (validatedEl.type === 'text') {
ctx.font = `${validatedEl.fontSize}px ${validatedEl.fontFamily}`;
ctx.fillStyle = validatedEl.fill;
ctx.textBaseline = 'top';
ctx.save();
ctx.translate(validatedEl.x, validatedEl.y);
ctx.rotate(((validatedEl.rotation || 0) * Math.PI) / 180);
ctx.fillText(validatedEl.text, 0, 0);
ctx.restore();
// Draw selection box and resize handles if selected
if (selectedId === validatedEl.id) {
const textWidth = ctx.measureText(validatedEl.text).width;
const textHeight = validatedEl.fontSize;
ctx.strokeStyle = '#FF6B6B';
ctx.lineWidth = 2;
ctx.strokeRect(validatedEl.x - 5, validatedEl.y - 5, textWidth + 10, textHeight + 10);
// Draw resize handles
const handleSize = 8;
const handles = [
{ x: validatedEl.x - handleSize / 2, y: validatedEl.y - handleSize / 2 }, // top-left
{ x: validatedEl.x + textWidth + handleSize / 2, y: validatedEl.y - handleSize / 2 }, // top-right
{ x: validatedEl.x - handleSize / 2, y: validatedEl.y + textHeight + handleSize / 2 }, // bottom-left
{ x: validatedEl.x + textWidth + handleSize / 2, y: validatedEl.y + textHeight + handleSize / 2 }, // bottom-right
];
handles.forEach((handle) => {
ctx.fillStyle = '#FF6B6B';
ctx.fillRect(handle.x - handleSize / 2, handle.y - handleSize / 2, handleSize, handleSize);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1;
ctx.strokeRect(handle.x - handleSize / 2, handle.y - handleSize / 2, handleSize, handleSize);
});
}
} else if (validatedEl.type === 'image') {
const img = new Image();
img.onload = () => {
ctx.save();
ctx.translate(validatedEl.x, validatedEl.y);
ctx.rotate(((validatedEl.rotation || 0) * Math.PI) / 180);
ctx.drawImage(img, 0, 0, validatedEl.width, validatedEl.height);
// Draw selection box if selected
if (selectedId === validatedEl.id) {
ctx.strokeStyle = '#FF6B6B';
ctx.lineWidth = 2;
ctx.strokeRect(-5, -5, validatedEl.width + 10, validatedEl.height + 10);
// Draw resize handles
const handleSize = 8;
const handles = [
{ x: -handleSize / 2, y: -handleSize / 2 }, // top-left
{ x: validatedEl.width + handleSize / 2, y: -handleSize / 2 }, // top-right
{ x: -handleSize / 2, y: validatedEl.height + handleSize / 2 }, // bottom-left
{ x: validatedEl.width + handleSize / 2, y: validatedEl.height + handleSize / 2 }, // bottom-right
];
handles.forEach((handle) => {
ctx.fillStyle = '#FF6B6B';
ctx.fillRect(handle.x - handleSize / 2, handle.y - handleSize / 2, handleSize, handleSize);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1;
ctx.strokeRect(handle.x - handleSize / 2, handle.y - handleSize / 2, handleSize, handleSize);
});
}
ctx.restore();
};
img.src = validatedEl.src;
}
});
}, [design, side, selectedId, product, dragging]);
const addTextElement = useCallback(() => {
const id = uuid();
// Calculate initial position centered in the print area
const printArea = side === 'front' ? product.printArea : product.printAreaBack || product.printArea;
const minX = Math.round(printArea.xPct * DISPLAY);
const minY = Math.round(printArea.yPct * DISPLAY);
const printWidth = Math.round(printArea.wPct * DISPLAY);
const printHeight = Math.round(printArea.hPct * DISPLAY);
// Measure actual text width
const fontSize = 24;
const textWidth = measureTextWidth('New Text', fontSize, 'Arial');
// Center horizontally and vertically in print area
const centerX = minX + Math.round((printWidth - textWidth) / 2);
const centerY = minY + Math.round((printHeight - fontSize) / 2);
const newElement: DesignElement = {
id,
type: 'text',
text: 'New Text',
x: Math.max(minX, centerX),
y: Math.max(minY, centerY),
fontSize,
fontFamily: 'Arial',
fill: '#000000',
rotation: 0,
};
setDesign((prev) => ({
...prev,
[side]: [...prev[side], newElement],
}));
setSelectedId(id);
setPendingId(id);
}, [side, product]);
const updateElement = useCallback(
(id: string, updates: Partial<DesignElement>) => {
setDesign((prev) => ({
...prev,
[side]: prev[side].map((el) =>
el.id === id ? { ...el, ...updates } : el
),
}));
},
[side]
);
const addImageElement = useCallback(() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (evt) => {
const id = uuid();
const newElement: DesignElement = {
id,
type: 'image',
src: evt.target?.result as string,
x: 50,
y: 50,
width: 100,
height: 100,
rotation: 0,
};
setDesign((prev) => ({
...prev,
[side]: [...prev[side], newElement],
}));
setSelectedId(id);
setPendingId(id);
};
reader.readAsDataURL(file);
}
};
input.click();
}, [side]);
const confirmElement = useCallback(() => {
setPendingId(null);
}, []);
const cancelElement = useCallback(() => {
if (!pendingId) return;
setDesign((prev) => ({
...prev,
[side]: prev[side].filter((el) => el.id !== pendingId),
}));
setSelectedId(null);
setPendingId(null);
}, [pendingId, side]);
const deleteElement = useCallback(
(id: string) => {
setDesign((prev) => ({
...prev,
[side]: prev[side].filter((el) => el.id !== id),
}));
setSelectedId(null);
},
[side]
);
const handleCanvasClick = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = e.currentTarget;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const elements = side === 'front' ? design.front : design.back;
let clicked = false;
for (const el of elements) {
if (el.type === 'text') {
if (x >= el.x && x <= el.x + 200 && y >= el.y && y <= el.y + el.fontSize + 10) {
setSelectedId(el.id);
setPendingId(null);
clicked = true;
break;
}
} else if (el.type === 'image') {
if (x >= el.x && x <= el.x + el.width && y >= el.y && y <= el.y + el.height) {
setSelectedId(el.id);
setPendingId(null);
clicked = true;
break;
}
}
}
if (!clicked) {
setSelectedId(null);
setPendingId(null);
}
},
[design, side]
);
const handleCanvasMouseDown = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
if (!selectedId) return;
const canvas = e.currentTarget;
const rect = canvas.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const clickY = e.clientY - rect.top;
const elements = side === 'front' ? design.front : design.back;
const element = elements.find((el) => el.id === selectedId);
if (!element) return;
// Determine if clicking on a resize handle or the element itself
let dragType: 'move' | 'resize-nw' | 'resize-ne' | 'resize-sw' | 'resize-se' = 'move';
if (element.type === 'text') {
const textWidth = measureTextWidth(element.text, element.fontSize, element.fontFamily);
const textHeight = element.fontSize;
const handleSize = 8;
// Check if click is within handleSize of each corner
const nearTopLeft = Math.abs(clickX - element.x) < handleSize && Math.abs(clickY - element.y) < handleSize;
const nearTopRight = Math.abs(clickX - (element.x + textWidth)) < handleSize && Math.abs(clickY - element.y) < handleSize;
const nearBottomLeft = Math.abs(clickX - element.x) < handleSize && Math.abs(clickY - (element.y + textHeight)) < handleSize;
const nearBottomRight = Math.abs(clickX - (element.x + textWidth)) < handleSize && Math.abs(clickY - (element.y + textHeight)) < handleSize;
if (nearTopLeft) dragType = 'resize-nw';
else if (nearTopRight) dragType = 'resize-ne';
else if (nearBottomLeft) dragType = 'resize-sw';
else if (nearBottomRight) dragType = 'resize-se';
} else if (element.type === 'image') {
const handleSize = 10;
// Check if click is within handleSize of each corner
const nearTopLeft = Math.abs(clickX - element.x) < handleSize && Math.abs(clickY - element.y) < handleSize;
const nearTopRight = Math.abs(clickX - (element.x + element.width)) < handleSize && Math.abs(clickY - element.y) < handleSize;
const nearBottomLeft = Math.abs(clickX - element.x) < handleSize && Math.abs(clickY - (element.y + element.height)) < handleSize;
const nearBottomRight = Math.abs(clickX - (element.x + element.width)) < handleSize && Math.abs(clickY - (element.y + element.height)) < handleSize;
if (nearTopLeft) dragType = 'resize-nw';
else if (nearTopRight) dragType = 'resize-ne';
else if (nearBottomLeft) dragType = 'resize-sw';
else if (nearBottomRight) dragType = 'resize-se';
}
setDragging({
id: selectedId,
type: dragType,
startX: clickX,
startY: clickY,
elementX: element.x,
elementY: element.y,
offsetX: element.x - clickX,
offsetY: element.y - clickY,
});
},
[selectedId, side, design]
);
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!dragging) return;
const canvas = side === 'front' ? frontCanvasRef.current : backCanvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const currentX = e.clientX - rect.left;
const currentY = e.clientY - rect.top;
const elements = side === 'front' ? design.front : design.back;
const element = elements.find((el) => el.id === dragging.id);
if (element) {
// Calculate print area bounds
const printArea = side === 'front' ? product.printArea : product.printAreaBack || product.printArea;
const minX = Math.round(printArea.xPct * DISPLAY);
const minY = Math.round(printArea.yPct * DISPLAY);
const maxX = minX + Math.round(printArea.wPct * DISPLAY);
const maxY = minY + Math.round(printArea.hPct * DISPLAY);
const printAreaWidth = maxX - minX;
const printAreaHeight = maxY - minY;
if (dragging.type === 'move') {
// Position element based on initial element position + mouse delta
const dx = currentX - dragging.startX;
const dy = currentY - dragging.startY;
let newX = dragging.elementX + dx;
let newY = dragging.elementY + dy;
// Constrain to print area boundaries - ensure element stays fully inside
let elementWidth = element.width || 0;
let elementHeight = element.height || element.fontSize || 0;
// For text, measure actual width
if (element.type === 'text') {
elementWidth = measureTextWidth(element.text, element.fontSize, element.fontFamily) + 10;
elementHeight = element.fontSize * 1.2; // Account for line height
}
// Constrain to print area - allow some overflow if element is too large
// X-axis: keep left edge from going too far left, but allow right to extend
const maxLeftX = maxX - 20; // Allow small overlap on right
newX = Math.max(minX - elementWidth + 20, Math.min(newX, maxLeftX));
// Y-axis: similar approach
const maxTopY = maxY - 10;
newY = Math.max(minY - elementHeight + 10, Math.min(newY, maxTopY));
updateElement(dragging.id, { x: newX, y: newY });
} else if (element.type === 'text') {
// Resize text by changing font size based on vertical drag
const dy = currentY - dragging.startY;
const newSize = Math.max(8, Math.min(72, element.fontSize + Math.round(dy / 10)));
updateElement(dragging.id, { fontSize: newSize });
} else if (element.type === 'image') {
// Resize image from corners with proper boundary handling
const dx = currentX - dragging.startX;
const dy = currentY - dragging.startY;
let newX = element.x;
let newY = element.y;
let newWidth = element.width;
let newHeight = element.height;
// Handle corner-specific resize logic
if (dragging.type === 'resize-se') {
// Southeast: expand right and down
newWidth = Math.max(20, element.width + Math.round(dx / 2));
newHeight = Math.max(20, element.height + Math.round(dy / 2));
} else if (dragging.type === 'resize-sw') {
// Southwest: expand left and down
newX = element.x + Math.round(dx / 2);
newWidth = Math.max(20, element.width - Math.round(dx / 2));
newHeight = Math.max(20, element.height + Math.round(dy / 2));
} else if (dragging.type === 'resize-ne') {
// Northeast: expand right and up
newY = element.y + Math.round(dy / 2);
newWidth = Math.max(20, element.width + Math.round(dx / 2));
newHeight = Math.max(20, element.height - Math.round(dy / 2));
} else if (dragging.type === 'resize-nw') {
// Northwest: expand left and up
newX = element.x + Math.round(dx / 2);
newY = element.y + Math.round(dy / 2);
newWidth = Math.max(20, element.width - Math.round(dx / 2));
newHeight = Math.max(20, element.height - Math.round(dy / 2));
}
// Constrain to print area boundaries
newX = Math.max(minX, Math.min(newX, maxX - 20));
newY = Math.max(minY, Math.min(newY, maxY - 20));
newWidth = Math.min(newWidth, maxX - newX);
newHeight = Math.min(newHeight, maxY - newY);
updateElement(dragging.id, { x: newX, y: newY, width: newWidth, height: newHeight });
}
}
},
[dragging, side, design, product, updateElement]
);
const handleMouseUp = useCallback(() => {
setDragging(null);
}, []);
useEffect(() => {
if (dragging) {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}
}, [dragging, handleMouseMove, handleMouseUp]);
const selectedElement = selectedId
? (side === 'front' ? design.front : design.back).find((el) => el.id === selectedId)
: null;
const printArea = side === 'front' ? product.printArea : product.printAreaBack || product.printArea;
const canvasW = Math.round(printArea.wPct * DISPLAY);
const canvasH = Math.round(printArea.hPct * DISPLAY);
const handleAddToCart = async () => {
const customer = await checkCustomerAuth();
if (!customer) {
setShowLoginPrompt(true);
return;
}
const priceInfo = getEffectivePrice(product, [], new Date(), 'personalised');
addItem({
productId: product.id,
quantity: 1,
designJson: JSON.stringify(design),
designWidthCm: product.referenceWidthCm || null,
designHeightCm: product.referenceHeightCm || null,
price: priceInfo.price,
});
};
return (
<div className="space-y-8">
{showLoginPrompt && <LoginPromptModal onClose={() => setShowLoginPrompt(false)} />}
<div className="p-6 bg-paper rounded-lg border border-line">
<h3 className="text-lg font-medium mb-4">Design Canvas</h3>
<div className="grid grid-cols-3 gap-6">
{/* Canvas */}
<div className="col-span-2">
<div className="flex gap-2 mb-4">
<button
onClick={() => {
setSide('front');
setSelectedId(null);
}}
className={`px-4 py-2 rounded ${side === 'front' ? 'bg-clay text-paper' : 'bg-line text-ink'}`}
>
Front
</button>
<button
onClick={() => {
setSide('back');
setSelectedId(null);
}}
className={`px-4 py-2 rounded ${side === 'back' ? 'bg-clay text-paper' : 'bg-line text-ink'}`}
>
Back
</button>
</div>
{/* Color Picker */}
{product.colors.length > 1 && (
<div className="mt-4 flex gap-2 items-center">
<span className="text-sm font-medium">Garment Color:</span>
<div className="flex gap-2">
{product.colors.map((color) => (
<button
key={color}
onClick={() => setSelectedColor(color)}
className={`w-8 h-8 rounded border-2 transition-all ${
selectedColor === color
? 'border-clay ring-2 ring-clay'
: 'border-line hover:border-clay'
}`}
style={{ backgroundColor: color }}
title={color}
aria-label={`Color ${color}`}
/>
))}
</div>
</div>
)}
<div ref={canvasContainerRef} className="bg-surface border border-line rounded p-4 relative inline-block mt-4">
<div
style={{
width: DISPLAY,
height: DISPLAY,
position: 'relative',
overflow: 'hidden',
}}
>
{/* Product image - prioritize colorPhotos over imageUrl */}
{customPhoto && side === 'front' ? (
<img
src={customPhoto}
alt="Product front"
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : side === 'front' && product.colorPhotos?.[selectedColor] && product.colorPhotos[selectedColor] !== product.imageUrl ? (
<img
src={product.colorPhotos[selectedColor]}
alt="Product front"
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : side === 'front' && product.imageUrl ? (
<img
src={product.imageUrl}
alt="Product front"
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : side === 'back' && product.colorPhotosBack?.[selectedColor] ? (
<img
src={product.colorPhotosBack[selectedColor]}
alt="Product back"
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : side === 'back' && product.imageUrlBack && product.imageUrlBack !== product.imageUrl ? (
<img
src={product.imageUrlBack}
alt="Product back"
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : side === 'back' && product.colorPhotos?.[selectedColor] ? (
<img
src={product.colorPhotos[selectedColor]}
alt="Product back (using front color photo)"
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : side === 'back' && product.imageUrl ? (
<img
src={product.imageUrl}
alt="Product back (using front image)"
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
<ProductMockup mockup={product.mockup} color={selectedColor} />
)}
{/* Print area guide */}
<div
style={{
position: 'absolute',
top: `${printArea.yPct * DISPLAY}px`,
left: `${printArea.xPct * DISPLAY}px`,
width: `${printArea.wPct * DISPLAY}px`,
height: `${printArea.hPct * DISPLAY}px`,
border: '2px dashed #E5227E',
boxShadow: 'inset 0 0 0 1px rgba(229, 34, 126, 0.3)',
backgroundColor: 'rgba(229, 34, 126, 0.02)',
pointerEvents: 'none',
}}
/>
{/* Front Canvas */}
{side === 'front' && (
<canvas
ref={frontCanvasRef}
width={canvasW}
height={canvasH}
onClick={handleCanvasClick}
onMouseDown={handleCanvasMouseDown}
style={{
position: 'absolute',
top: `${printArea.yPct * DISPLAY}px`,
left: `${printArea.xPct * DISPLAY}px`,
backgroundColor: 'transparent',
cursor: selectedId ? 'grab' : 'pointer',
}}
/>
)}
{/* Back Canvas */}
{side === 'back' && (
<canvas
ref={backCanvasRef}
width={canvasW}
height={canvasH}
onClick={handleCanvasClick}
onMouseDown={handleCanvasMouseDown}
style={{
position: 'absolute',
top: `${printArea.yPct * DISPLAY}px`,
left: `${printArea.xPct * DISPLAY}px`,
backgroundColor: 'transparent',
cursor: selectedId ? 'grab' : 'pointer',
}}
/>
)}
</div>
</div>
<div className="mt-4 flex gap-2">
<button
onClick={addTextElement}
className="px-4 py-2 bg-clay text-paper rounded hover:bg-clay-dark"
>
Add Text
</button>
<button
onClick={addImageElement}
className="px-4 py-2 bg-clay text-paper rounded hover:bg-clay-dark"
>
Add Image
</button>
</div>
</div>
{/* Properties Panel */}
<div className="bg-line rounded p-4 space-y-4 h-fit">
<div className="text-sm font-medium">Properties</div>
{selectedElement ? (
<>
<div>
<label className="text-xs font-medium">Element Type</label>
<div className="text-xs text-muted capitalize mt-1">
{selectedElement.type === 'text' ? 'Text' : 'Image'}
</div>
</div>
{selectedElement.type === 'text' && (
<>
<div>
<label className="text-xs font-medium">Text</label>
<input
type="text"
value={selectedElement.text}
onChange={(e) => updateElement(selectedElement.id, { text: e.target.value })}
className="w-full text-xs border border-line rounded px-2 py-1 mt-1 text-ink"
/>
</div>
<div>
<label className="text-xs font-medium">Font</label>
<select
value={selectedElement.fontFamily}
onChange={(e) => updateElement(selectedElement.id, { fontFamily: e.target.value })}
className="w-full text-xs border border-line rounded px-2 py-1 mt-1 text-ink"
>
{FONTS.map((font) => (
<option key={font} value={font}>
{font}
</option>
))}
</select>
</div>
<div>
<label className="text-xs font-medium">Size</label>
<div className="flex gap-2 mt-1">
<input
type="number"
value={selectedElement.fontSize}
onChange={(e) => updateElement(selectedElement.id, { fontSize: parseInt(e.target.value) })}
className="flex-1 text-xs border border-line rounded px-2 py-1 text-ink"
min="8"
max="72"
/>
<span className="text-xs text-muted self-center">px</span>
</div>
<p className="text-xs text-muted mt-1">Drag corners on canvas to resize</p>
</div>
<div>
<label className="text-xs font-medium">Color</label>
<input
type="color"
value={selectedElement.fill}
onChange={(e) => updateElement(selectedElement.id, { fill: e.target.value })}
className="w-full h-8 border border-line rounded mt-1 cursor-pointer"
/>
</div>
</>
)}
{selectedElement.type === 'image' && (
<>
<div>
<label className="text-xs font-medium">Width</label>
<input
type="number"
value={selectedElement.width}
onChange={(e) => updateElement(selectedElement.id, { width: parseInt(e.target.value) })}
className="w-full text-xs border border-line rounded px-2 py-1 mt-1 text-ink"
min="20"
max="300"
/>
</div>
<div>
<label className="text-xs font-medium">Height</label>
<input
type="number"
value={selectedElement.height}
onChange={(e) => updateElement(selectedElement.id, { height: parseInt(e.target.value) })}
className="w-full text-xs border border-line rounded px-2 py-1 mt-1 text-ink"
min="20"
max="300"
/>
</div>
<p className="text-xs text-muted">Drag corners on canvas to resize</p>
</>
)}
{pendingId === selectedElement.id && (
<div className="flex gap-2">
<button
onClick={confirmElement}
className="flex-1 px-3 py-2 bg-splash-blue text-paper rounded text-xs font-medium hover:bg-splash-blue/90"
>
Confirm
</button>
<button
onClick={cancelElement}
className="flex-1 px-3 py-2 bg-splash-pink text-paper rounded text-xs font-medium hover:bg-splash-pink/90"
>
Cancel
</button>
</div>
)}
{pendingId !== selectedElement.id && (
<button
onClick={() => deleteElement(selectedElement.id)}
className="w-full px-3 py-2 bg-splash-pink text-paper rounded text-xs font-medium hover:bg-splash-pink/90"
>
Delete Element
</button>
)}
</>
) : (
<div className="text-xs text-muted">Click on text or image to edit</div>
)}
</div>
</div>
{/* Add to Cart */}
<div className="flex gap-4 items-center pt-6 border-t border-line mt-6">
<Price cents={getEffectivePrice(product, [], new Date(), 'personalised').price} />
<button
onClick={handleAddToCart}
className="px-6 py-3 bg-clay text-paper rounded font-medium hover:bg-clay-dark"
>
Add to Cart
</button>
</div>
</div>
</div>
);
}
+390
View File
@@ -0,0 +1,390 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { v4 as uuid } from 'uuid';
import type { DesignElement, ProductDTO } from '@/lib/types';
const DISPLAY = 560; // Canvas display size
const FONTS = ['Arial', 'Georgia', 'Courier New', 'Times New Roman', 'Verdana', 'Comic Sans MS'];
interface FabricDesignCanvasV2Props {
product: ProductDTO;
customPhoto?: string;
}
// Lazy load Fabric.js
let fabricLib: any = null;
const loadFabric = async () => {
if (fabricLib) return fabricLib;
try {
console.log('Loading fabric.js...');
// Import fabric.js - it exports Canvas, Text, Image, Rect as named exports
const mod = await import('fabric');
console.log('Fabric module loaded, checking exports...');
console.log('Available exports:', Object.keys(mod).slice(0, 20));
if (!mod.Canvas) {
throw new Error('Canvas not found in fabric module exports');
}
fabricLib = {
Canvas: mod.Canvas,
Text: mod.Text,
Image: mod.Image,
Rect: mod.Rect,
};
console.log('Fabric.js setup completed');
return fabricLib;
} catch (err) {
console.error('Failed to import fabric.js:', err);
// Return empty object instead of throwing to prevent infinite errors
return {};
}
};
export default function FabricDesignCanvasV2({ product, customPhoto }: FabricDesignCanvasV2Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const fabricCanvasRef = useRef<any>(null);
const isMountedRef = useRef(false);
const [design, setDesign] = useState<{ front: DesignElement[]; back: DesignElement[] }>({
front: [],
back: [],
});
const [side, setSide] = useState<'front' | 'back'>('front');
const [selectedColor, setSelectedColor] = useState<string>(product.colors[0]);
const [history, setHistory] = useState<Array<{ front: DesignElement[]; back: DesignElement[] }>>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const [isLoaded, setIsLoaded] = useState(false);
// Track client mounting to avoid hydration issues
useEffect(() => {
isMountedRef.current = true;
}, []);
// Initialize Fabric.js canvas only on client
useEffect(() => {
if (!isMountedRef.current) return;
let mounted = true;
const init = async () => {
if (!canvasRef.current || fabricCanvasRef.current) return;
try {
const fabLib = await loadFabric();
if (!mounted) return;
const Canvas = fabLib.Canvas;
if (!Canvas) {
throw new Error(`Canvas is undefined. fabLib keys: ${Object.keys(fabLib).join(', ')}`);
}
// Create canvas instance
const fabricCanvas = new Canvas(canvasRef.current, {
width: DISPLAY,
height: DISPLAY,
backgroundColor: 'transparent',
});
fabricCanvasRef.current = fabricCanvas;
// Handle object selection
fabricCanvas.on('object:added', () => saveHistory());
fabricCanvas.on('object:modified', () => saveHistory());
fabricCanvas.on('object:removed', () => saveHistory());
if (mounted) {
setIsLoaded(true);
}
} catch (err) {
console.error('Failed to initialize Fabric.js canvas:', err);
if (mounted) {
setIsLoaded(true); // Still mark as loaded to prevent infinite retry
}
}
};
init();
return () => {
mounted = false;
if (fabricCanvasRef.current) {
try {
fabricCanvasRef.current.dispose();
fabricCanvasRef.current = null;
} catch (e) {
// Ignore disposal errors
}
}
};
}, []);
// Render design elements on canvas
useEffect(() => {
if (!fabricCanvasRef.current || !isLoaded) return;
const canvas = fabricCanvasRef.current;
canvas.clear();
const render = async () => {
try {
const { Text, Image, Rect } = await loadFabric();
const printArea = side === 'front' ? product.printArea : product.printAreaBack || product.printArea;
// Print area is stored as 0-1 decimals; convert to pixels on 560x560 canvas
const minX = Math.round(printArea.xPct * DISPLAY);
const minY = Math.round(printArea.yPct * DISPLAY);
const width = Math.round(printArea.wPct * DISPLAY);
const height = Math.round(printArea.hPct * DISPLAY);
const rect = new Rect({
left: minX,
top: minY,
width,
height,
fill: 'transparent',
stroke: '#E5227E',
strokeDashArray: [5, 5],
strokeWidth: 2,
selectable: false,
hasControls: false,
});
canvas.add(rect);
// Render design elements
const elements = side === 'front' ? design.front : design.back;
elements.forEach((el) => {
if (el.type === 'text') {
const text = new Text(el.text, {
left: el.x,
top: el.y,
fontSize: el.fontSize,
fontFamily: el.fontFamily,
fill: el.fill,
selectable: true,
hasControls: true,
});
canvas.add(text);
}
// TODO: Add image support after fixing text rendering
});
canvas.renderAll();
} catch (err) {
console.error('Failed to render canvas:', err);
}
};
render();
}, [design, side, isLoaded]);
const renderProductImage = async () => {
console.log('renderProductImage called');
if (!fabricCanvasRef.current) {
console.log('Canvas ref not ready');
return;
}
const canvas = fabricCanvasRef.current;
const printArea = side === 'front' ? product.printArea : product.printAreaBack || product.printArea;
// Get Image and Rect classes
const { Image, Rect } = await loadFabric();
// Determine which image to use
let imageUrl: string | null = null;
console.log('Rendering product image for side:', side);
console.log('Product data:', { colorPhotos: product.colorPhotos, imageUrl: product.imageUrl, colorPhotosBack: product.colorPhotosBack, imageUrlBack: product.imageUrlBack, selectedColor });
if (customPhoto && side === 'front') {
imageUrl = customPhoto;
console.log('Using customPhoto:', imageUrl);
} else if (side === 'front' && product.colorPhotos?.[selectedColor]) {
imageUrl = product.colorPhotos[selectedColor];
console.log('Using colorPhoto for color:', selectedColor, imageUrl);
} else if (side === 'front' && product.imageUrl) {
imageUrl = product.imageUrl;
console.log('Using imageUrl:', imageUrl);
} else if (side === 'back' && product.colorPhotosBack?.[selectedColor]) {
imageUrl = product.colorPhotosBack[selectedColor];
console.log('Using colorPhotoBack for color:', selectedColor, imageUrl);
} else if (side === 'back' && product.imageUrlBack) {
imageUrl = product.imageUrlBack;
console.log('Using imageUrlBack:', imageUrl);
} else if (side === 'back' && product.colorPhotos?.[selectedColor]) {
imageUrl = product.colorPhotos[selectedColor];
console.log('Fallback: Using colorPhoto for back:', selectedColor, imageUrl);
}
// Draw print area guide only
const minX = Math.round(printArea.xPct * DISPLAY);
const minY = Math.round(printArea.yPct * DISPLAY);
const width = Math.round(printArea.wPct * DISPLAY);
const height = Math.round(printArea.hPct * DISPLAY);
const rect = new Rect({
left: minX,
top: minY,
width,
height,
fill: 'transparent',
stroke: '#E5227E',
strokeDashArray: [5, 5],
strokeWidth: 2,
selectable: false,
hasControls: false,
});
canvas.add(rect);
};
const saveHistory = () => {
setHistory((prev) => [...prev.slice(0, historyIndex + 1), { ...design }]);
setHistoryIndex((prev) => prev + 1);
};
const addText = () => {
const newElement: DesignElement = {
id: uuid(),
type: 'text',
text: 'Click to edit',
x: DISPLAY / 2 - 50,
y: DISPLAY / 2 - 12,
fontSize: 24,
fontFamily: 'Arial',
fill: '#000000',
rotation: 0,
};
setDesign((prev) => ({
...prev,
[side]: [...prev[side], newElement],
}));
};
const addImage = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (evt) => {
const newElement: DesignElement = {
id: uuid(),
type: 'image',
src: evt.target?.result as string,
x: DISPLAY / 2 - 50,
y: DISPLAY / 2 - 50,
width: 100,
height: 100,
rotation: 0,
};
setDesign((prev) => ({
...prev,
[side]: [...prev[side], newElement],
}));
};
reader.readAsDataURL(file);
}
};
input.click();
};
return (
<div className="mx-auto max-w-6xl px-6 py-8">
<div className="grid gap-8 lg:grid-cols-3">
{/* Canvas Area */}
<div className="lg:col-span-2">
<div className="mb-4 flex gap-2 border-b border-line pb-4">
<button
onClick={() => setSide('front')}
className={`px-4 py-2 ${side === 'front' ? 'border-b-2 border-clay' : ''}`}
>
Front
</button>
{product.printAreaBack && (
<button
onClick={() => setSide('back')}
className={`px-4 py-2 ${side === 'back' ? 'border-b-2 border-clay' : ''}`}
>
Back
</button>
)}
</div>
{/* Color Swatches */}
<div className="mb-4">
<p className="mb-2 text-sm font-medium">Garment Color:</p>
<div className="flex gap-2">
{product.colors.map((color) => (
<button
key={color}
onClick={() => setSelectedColor(color)}
className={`h-8 w-8 rounded border-2 transition-all ${
selectedColor === color ? 'border-clay ring-2 ring-clay' : 'border-line'
}`}
style={{ backgroundColor: color }}
/>
))}
</div>
</div>
{/* Canvas with Product Image */}
<div className="border border-line bg-white p-4 relative inline-block w-full">
{/* Product image background */}
<img
src={product.colorPhotos?.[selectedColor] || product.imageUrl || ''}
alt="Product"
className="absolute inset-0 w-full h-full object-contain"
style={{ zIndex: 0 }}
/>
{/* Fabric canvas overlay */}
<canvas
ref={canvasRef}
style={{
position: 'relative',
zIndex: 1,
display: 'block'
}}
/>
</div>
{/* Controls */}
<div className="mt-4 flex gap-2">
<button
onClick={addText}
className="rounded bg-clay px-4 py-2 text-sm font-medium text-paper hover:bg-clay/90"
>
Add Text
</button>
<button
onClick={addImage}
className="rounded bg-clay px-4 py-2 text-sm font-medium text-paper hover:bg-clay/90"
>
Add Image
</button>
</div>
</div>
{/* Sidebar */}
<div>
<div className="rounded border border-line bg-surface p-4">
<h3 className="mb-4 font-medium">Properties</h3>
<p className="text-sm text-muted">Click on text or image to edit</p>
<div className="mt-4">
<p className="mb-4 font-display text-xl">
£{(product.effectivePrice / 100).toFixed(2)}
</p>
<button className="w-full rounded bg-clay px-4 py-2 text-sm font-medium text-paper hover:bg-clay/90">
Add to Cart
</button>
</div>
</div>
</div>
</div>
</div>
);
}
+3 -1
View File
@@ -1,10 +1,12 @@
'use client';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import NewsletterForm from './NewsletterForm'; import NewsletterForm from './NewsletterForm';
export default function Footer() { export default function Footer() {
return ( return (
<footer className="border-t border-line"> <footer suppressHydrationWarning className="border-t border-line">
<div className="h-1 w-full brand-gradient" /> <div className="h-1 w-full brand-gradient" />
<div className="mx-auto max-w-6xl px-6 py-10"> <div className="mx-auto max-w-6xl px-6 py-10">
<div className="grid gap-8 md:grid-cols-4"> <div className="grid gap-8 md:grid-cols-4">
+4 -4
View File
@@ -30,8 +30,8 @@ export default async function Header() {
links: [ links: [
{ href: `/made-to-order?category=${c.slug}`, label: 'All' }, { href: `/made-to-order?category=${c.slug}`, label: 'All' },
...c.children ...c.children
.filter((ch) => ch.showOnPersonalised) .filter((ch: any) => ch.showOnPersonalised)
.map((child) => ({ href: `/made-to-order?category=${child.slug}`, label: child.name })), .map((child: any) => ({ href: `/made-to-order?category=${child.slug}`, label: child.name })),
], ],
})), })),
]; ];
@@ -43,8 +43,8 @@ export default async function Header() {
links: [ links: [
{ href: `/products?category=${c.slug}`, label: 'All' }, { href: `/products?category=${c.slug}`, label: 'All' },
...c.children ...c.children
.filter((ch) => ch.showOnProducts) .filter((ch: any) => ch.showOnProducts)
.map((child) => ({ href: `/products?category=${child.slug}`, label: child.name })), .map((child: any) => ({ href: `/products?category=${child.slug}`, label: child.name })),
], ],
})), })),
]; ];
+2 -1
View File
@@ -23,8 +23,9 @@ export default function NewsletterForm() {
}} }}
className="flex flex-col gap-2" className="flex flex-col gap-2"
> >
<div className="flex gap-2"> <div className="flex gap-2" suppressHydrationWarning>
<input <input
suppressHydrationWarning
type="email" type="email"
required required
value={email} value={email}
+9 -3
View File
@@ -11,10 +11,12 @@ export default function PhotoPrintAreaField({
variant = 'front', variant = 'front',
existingImageUrl, existingImageUrl,
existingPrintArea, existingPrintArea,
colorPhotosPreview,
}: { }: {
variant?: 'front' | 'back'; variant?: 'front' | 'back';
existingImageUrl?: string; existingImageUrl?: string;
existingPrintArea?: Box; existingPrintArea?: Box;
colorPhotosPreview?: Record<string, string>;
}) { }) {
const [previewUrl, setPreviewUrl] = useState<string | null>(null); const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [box, setBox] = useState<Box>(existingPrintArea || DEFAULT_BOX); const [box, setBox] = useState<Box>(existingPrintArea || DEFAULT_BOX);
@@ -60,7 +62,9 @@ export default function PhotoPrintAreaField({
setShowPrintAreaEditor(false); setShowPrintAreaEditor(false);
}; };
const currentImageUrl = previewUrl || (isRemoved ? null : existingImageUrl); // Use preview from new upload, or existing base image, or first colorPhoto if available
const colorPhotoPreview = colorPhotosPreview ? Object.values(colorPhotosPreview)[0] : null;
const currentImageUrl = previewUrl || (isRemoved ? null : existingImageUrl) || colorPhotoPreview;
const onBoxMouseDown = (e: React.MouseEvent) => { const onBoxMouseDown = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
@@ -130,12 +134,14 @@ export default function PhotoPrintAreaField({
Uploading a real photo replaces the illustration everywhere this product is shown. One Uploading a real photo replaces the illustration everywhere this product is shown. One
tradeoff: the color swatches below can no longer recolor a photo the way they recolor the tradeoff: the color swatches below can no longer recolor a photo the way they recolor the
illustration they&apos;ll still be saved with each order, but the picture itself stays illustration they&apos;ll still be saved with each order, but the picture itself stays
fixed. Leave this blank to keep using the illustration (which does recolor live). fixed. Leave this blank to keep using the illustration (which does recolor live). Use the
print area editor below to set where designs can be placed.
</p> </p>
) : ( ) : (
<p className="mt-1 text-xs text-muted"> <p className="mt-1 text-xs text-muted">
If set, customers get a Front/Back toggle and can add a separate design to the back If set, customers get a Front/Back toggle and can add a separate design to the back
drag a print-area box below just like the front. drag a print-area box below just like the front. Use the print area editor to set where
designs can be placed on the back.
</p> </p>
)} )}
+3 -1
View File
@@ -60,11 +60,13 @@ export default function StandardProductView({ product }: { product: ProductDTO }
size, size,
quantity, quantity,
unitPrice: product.effectivePrice, unitPrice: product.effectivePrice,
designJson: { front: [], back: [] }, designJson: { front: [], back: [] } as any,
designPreviewUrl: product.colorPhotos[color] ?? product.imageUrl ?? '', designPreviewUrl: product.colorPhotos[color] ?? product.imageUrl ?? '',
designPreviewUrlBack: product.colorPhotosBack[color] ?? product.imageUrlBack ?? null, designPreviewUrlBack: product.colorPhotosBack[color] ?? product.imageUrlBack ?? null,
placementPreviewUrl: product.colorPhotos[color] ?? product.imageUrl ?? null, placementPreviewUrl: product.colorPhotos[color] ?? product.imageUrl ?? null,
placementPreviewUrlBack: product.colorPhotosBack[color] ?? product.imageUrlBack ?? null, placementPreviewUrlBack: product.colorPhotosBack[color] ?? product.imageUrlBack ?? null,
designWidthCm: null,
designHeightCm: null,
}); });
setShowAddToCartModal(true); setShowAddToCartModal(true);
}; };
+14
View File
@@ -0,0 +1,14 @@
'use client';
import { useEffect } from 'react';
export default function ThemeInitializer() {
useEffect(() => {
const stored = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDark = stored ? stored === 'dark' : prefersDark;
if (isDark) document.documentElement.classList.add('dark');
}, []);
return null;
}
+13 -4
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import Script from 'next/script'; import Script from 'next/script';
declare global { declare global {
@@ -33,9 +33,13 @@ export default function Turnstile() {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const widgetIdRef = useRef<string | null>(null); const widgetIdRef = useRef<string | null>(null);
const [scriptReady, setScriptReady] = useState(false); const [scriptReady, setScriptReady] = useState(false);
const readyRef = useRef(false);
useEffect(() => { useLayoutEffect(() => {
if (typeof window !== 'undefined' && window.turnstile) setScriptReady(true); if (typeof window !== 'undefined' && window.turnstile && !readyRef.current) {
readyRef.current = true;
setScriptReady(true);
}
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -68,7 +72,12 @@ export default function Turnstile() {
<Script <Script
src="https://challenges.cloudflare.com/turnstile/v0/api.js" src="https://challenges.cloudflare.com/turnstile/v0/api.js"
strategy="afterInteractive" strategy="afterInteractive"
onReady={() => setScriptReady(true)} onLoad={() => {
if (!readyRef.current) {
readyRef.current = true;
setScriptReady(true);
}
}}
/> />
<div ref={containerRef} /> <div ref={containerRef} />
</> </>
+4 -4
View File
@@ -23,7 +23,7 @@ export async function createAdminSession() {
.setExpirationTime(`${TOKEN_DURATION_SECONDS}s`) .setExpirationTime(`${TOKEN_DURATION_SECONDS}s`)
.sign(adminSecretKey()); .sign(adminSecretKey());
cookies().set(ADMIN_SESSION_COOKIE, token, { (await cookies()).set(ADMIN_SESSION_COOKIE, token, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: process.env.NODE_ENV === 'production',
sameSite: 'lax', sameSite: 'lax',
@@ -31,12 +31,12 @@ export async function createAdminSession() {
}); });
} }
export function clearAdminSession() { export async function clearAdminSession() {
cookies().delete(ADMIN_SESSION_COOKIE); (await cookies()).delete(ADMIN_SESSION_COOKIE);
} }
export async function isAdminAuthenticated() { export async function isAdminAuthenticated() {
const token = cookies().get(ADMIN_SESSION_COOKIE)?.value; const token = (await cookies()).get(ADMIN_SESSION_COOKIE)?.value;
if (!token) return false; if (!token) return false;
try { try {
+4 -4
View File
@@ -27,7 +27,7 @@ export async function createSession(customerId: string) {
.setExpirationTime(`${SESSION_DURATION_SECONDS}s`) .setExpirationTime(`${SESSION_DURATION_SECONDS}s`)
.sign(sessionSecretKey()); .sign(sessionSecretKey());
cookies().set(SESSION_COOKIE, token, { (await cookies()).set(SESSION_COOKIE, token, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: process.env.NODE_ENV === 'production',
sameSite: 'lax', sameSite: 'lax',
@@ -36,12 +36,12 @@ export async function createSession(customerId: string) {
}); });
} }
export function clearSession() { export async function clearSession() {
cookies().delete(SESSION_COOKIE); (await cookies()).delete(SESSION_COOKIE);
} }
export async function getCurrentCustomer() { export async function getCurrentCustomer() {
const token = cookies().get(SESSION_COOKIE)?.value; const token = (await cookies()).get(SESSION_COOKIE)?.value;
if (!token) return null; if (!token) return null;
try { try {
+4 -4
View File
@@ -26,8 +26,8 @@ function setCached<T>(key: string, data: T): void {
cache.set(key, { data, timestamp: Date.now() }); cache.set(key, { data, timestamp: Date.now() });
} }
export async function getCategoriesWithChildren() { export async function getCategoriesWithChildren(): Promise<any[]> {
const cached = getCached('categories_with_children'); const cached = getCached<any[]>('categories_with_children');
if (cached) return cached; if (cached) return cached;
const categories = await prisma.category.findMany({ const categories = await prisma.category.findMany({
@@ -56,8 +56,8 @@ export async function getCategoriesWithChildren() {
return categories; return categories;
} }
export async function getCollections() { export async function getCollections(): Promise<any[]> {
const cached = getCached('collections'); const cached = getCached<any[]>('collections');
if (cached) return cached; if (cached) return cached;
const collections = await prisma.collection.findMany({ const collections = await prisma.collection.findMany({
+1 -1
View File
@@ -30,7 +30,7 @@ export async function logFinancialChange(
reason?: string reason?: string
) { ) {
try { try {
const ip = getClientIp(headers()); const ip = getClientIp(await headers());
await prisma.financialAuditLog.create({ await prisma.financialAuditLog.create({
data: { data: {
+24 -6
View File
@@ -1,7 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2017", "target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"], "lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@@ -11,13 +15,27 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "react-jsx",
"incremental": true, "incremental": true,
"plugins": [{ "name": "next" }], "plugins": [
{
"name": "next"
}
],
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": [
"./src/*"
]
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": [
"exclude": ["node_modules"] "next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
} }