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",
"runtimeExecutable": "npm",
"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/made-to-order)",
"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/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// 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} */
const nextConfig = {
reactStrictMode: true,
eslint: { ignoreDuringBuilds: true },
serverExternalPackages: ['@prisma/client'],
experimental: {
serverComponentsExternalPackages: ['@prisma/client'],
// Invoice uploads (phone photos/PDFs) and product photo uploads go through
// server actions — the 1MB default rejects typical phone photos.
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": {
"@prisma/client": "5.16.1",
"bcryptjs": "^3.0.3",
"fabric": "^7.4.0",
"idb-keyval": "6.2.1",
"jose": "^6.2.3",
"konva": "9.3.14",
"next": "14.2.5",
"next": "^16.2.11",
"nodemailer": "6.9.14",
"pdf-parse": "^2.4.5",
"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.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 { success } = await verifyTurnstileToken(turnstileToken, ip);
if (!success) redirect('/account/register?error=captcha');
@@ -61,8 +61,9 @@ export async function loginCustomer(formData: FormData) {
const password = String(formData.get('password') ?? '');
const next = safeNext(formData.get('next'));
const ip = getClientIp(headers());
const userAgent = headers().get('user-agent') ?? undefined;
const headersList = await headers();
const ip = getClientIp(headersList);
const userAgent = headersList.get('user-agent') ?? undefined;
const { allowed } = await checkRateLimit(`login:customer:${ip}`, 5, 15 * 60 * 1000);
if (!allowed) {
@@ -95,7 +96,7 @@ export async function loginCustomer(formData: FormData) {
}
export async function logoutCustomer() {
clearSession();
await clearSession();
redirect('/');
}
+39
View File
@@ -47,6 +47,12 @@ export default async function AccountPage({ searchParams }: { searchParams: { su
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
const now = new Date();
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
@@ -121,6 +127,39 @@ export default async function AccountPage({ searchParams }: { searchParams: { su
</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>
{orders.length === 0 ? (
<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.' };
}
const ip = getClientIp(headers());
const ip = getClientIp(await headers());
const { success } = await verifyTurnstileToken(turnstileToken, ip);
if (!success) {
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.
const to = searchParams.to ? new Date(`${searchParams.to}T23:59:59`) : null;
let orders = [];
let manualSales = [];
let orders: any[] = [];
let manualSales: any[] = [];
try {
[orders, manualSales] = await Promise.all([
@@ -59,22 +59,22 @@ export default async function AccountsPage({
}
const entries: LedgerEntry[] = [
...orders.map((o) => ({
...orders.map((o: any) => ({
id: o.id,
kind: 'WEB' as const,
date: o.createdAt,
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)',
total: o.total,
href: `/admin/orders`,
})),
...manualSales.map((s) => ({
...manualSales.map((s: any) => ({
id: s.id,
kind: 'MANUAL' as const,
date: s.soldAt,
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,
total: s.total,
href: null,
+4 -4
View File
@@ -4,8 +4,8 @@ import AccountsNav from '@/components/AccountsNav';
import StockAdjustForm from '@/components/StockAdjustForm';
export default async function StockPage() {
let levels = [];
let productRows = [];
let levels: any[] = [];
let productRows: any[] = [];
try {
[levels, productRows] = await Promise.all([
@@ -18,7 +18,7 @@ export default async function StockPage() {
console.error('Error fetching stock data:', err);
}
const products = productRows.map((p) => ({
const products = productRows.map((p: any) => ({
id: p.id,
name: p.name,
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.
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.color !== b.color) return a.color.localeCompare(b.color);
return (a.size || '').localeCompare(b.size || '');
+3 -3
View File
@@ -14,14 +14,14 @@ export default async function CustomersPage({
}: {
searchParams: { sent?: string; failed?: string };
}) {
let entries = [];
let entries: any[] = [];
try {
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) {
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 (
<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 Link from 'next/link';
import dynamic from 'next/dynamic';
import type { ProductDTO } from '@/lib/types';
interface DesignApproval {
id: string;
customerEmail: string;
customerName: string | null;
productId: string;
product: {
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;
};
product: ProductDTO;
designJson: string;
color: string;
size: string | null;
+1
View File
@@ -57,6 +57,7 @@ export default function DesignDetailPage({ params }: { params: { id: string } })
if (res.ok) {
const updated = await res.json();
setDesign(updated);
alert(`✓ Design approved! Customer will receive an email with a link to order.${expectedDeliveryDate ? ` Estimated delivery: ${new Date(expectedDeliveryDate).toLocaleDateString()}` : ''}`);
}
} finally {
setApproving(false);
+7 -7
View File
@@ -4,7 +4,7 @@ import AdminNav from '@/components/AdminNav';
import DeleteDesignButton from '@/components/DeleteDesignButton';
export default async function DesignsPage() {
let designs = [];
let designs: any[] = [];
try {
designs = await prisma.designApproval.findMany({
select: {
@@ -20,15 +20,15 @@ export default async function DesignsPage() {
});
// 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({
where: { id: { in: productIds } },
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
designs = designs.map(d => ({
designs = designs.map((d: any) => ({
...d,
product: productMap[d.productId],
messages: [],
@@ -37,9 +37,9 @@ export default async function DesignsPage() {
console.error('Error fetching designs:', err);
}
const pending = designs.filter((d) => d.status === 'PENDING');
const approved = designs.filter((d) => d.status === 'APPROVED');
const rejected = designs.filter((d) => d.status === 'REJECTED');
const pending = designs.filter((d: any) => d.status === 'PENDING');
const approved = designs.filter((d: any) => d.status === 'APPROVED');
const rejected = designs.filter((d: any) => d.status === 'REJECTED');
const DesignList = ({ items, status }: { items: typeof designs; status: string }) => (
<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 next = safeNext(formData.get('next'));
const ip = getClientIp(headers());
const userAgent = headers().get('user-agent') ?? undefined;
const headersList = await headers();
const ip = getClientIp(headersList);
const userAgent = headersList.get('user-agent') ?? undefined;
const { allowed } = await checkRateLimit(`login:admin:${ip}`, 5, 15 * 60 * 1000);
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.',
};
export default function AdminLoginPage({ searchParams }: { searchParams: { error?: string; next?: string } }) {
const error = searchParams.error ? (ERROR_MESSAGES[searchParams.error] ?? 'Something went wrong.') : null;
const next = searchParams.next ?? '/admin/dashboard';
export default async function AdminLoginPage({ searchParams }: { searchParams: Promise<{ error?: string; next?: string }> }) {
const { error: errorKey, next } = await searchParams;
const error = errorKey ? (ERROR_MESSAGES[errorKey] ?? 'Something went wrong.') : null;
return (
<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) => {
let hasFrontDesign = false;
let hasBackDesign = false;
let design: { front: unknown[]; back: unknown[] } = { front: [], back: [] };
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;
hasBackDesign = (design.back?.length ?? 0) > 0;
} catch {
+5 -5
View File
@@ -37,7 +37,7 @@ export default async function OrdersPage({ searchParams }: { searchParams: { arc
const pageSize = 50;
const skip = (page - 1) * pageSize;
let orders = [];
let orders: any[] = [];
let total = 0;
try {
// 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
if (showOnlyPersonalised) {
orders = orders.filter((order) =>
order.items.some((item) => {
orders = orders.filter((order: any) =>
order.items.some((item: any) => {
const design = JSON.parse(item.designJson) as { front: unknown[]; back: unknown[] };
return design.front.length > 0 || design.back.length > 0;
})
@@ -148,8 +148,8 @@ export default async function OrdersPage({ searchParams }: { searchParams: { arc
</p>
) : (
<div className="mt-8 divide-y divide-line border-t border-line">
{orders.map((order) => {
const hasPersonalised = order.items.some((item) => {
{orders.map((order: any) => {
const hasPersonalised = order.items.some((item: any) => {
try {
const design = JSON.parse(item.designJson) as { front: unknown[]; back: unknown[] };
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 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 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();
const product = toProductDTO(row, activePromotions);
const categories = await prisma.category.findMany({
where: { parentId: null },
orderBy: { name: 'asc' },
select: {
id: true,
name: true,
slug: true,
include: {
children: {
select: { id: true, name: true, slug: true },
orderBy: { name: 'asc' },
},
},
});
}) as any;
const collections = await prisma.collection.findMany({ orderBy: { name: 'asc' } });
const occasions = collections.filter((c) => c.group === 'OCCASION');
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,
} : 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 (
<div className="mx-auto max-w-2xl px-6 py-12">
<AdminNav />
@@ -279,12 +280,12 @@ export default async function EditProductPage({ params }: { params: { id: string
<div>
<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>
<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>
+3 -3
View File
@@ -276,10 +276,10 @@ export async function updateProduct(formData: FormData) {
referenceWidthCm: number | null;
referenceHeightCm: number | null;
weightGrams: number | null;
imageUrl?: string;
imageUrlBack?: string;
imageUrl?: string | null;
imageUrlBack?: string | null;
printArea?: string;
printAreaBack?: string;
printAreaBack?: string | null;
} = {
name,
category,
+2 -6
View File
@@ -10,16 +10,12 @@ export default async function NewProductPage() {
const categories = await prisma.category.findMany({
where: { parentId: null },
orderBy: { name: 'asc' },
select: {
id: true,
name: true,
slug: true,
include: {
children: {
select: { id: true, name: true, slug: true },
orderBy: { name: 'asc' },
},
},
});
}) as any;
const collections = await prisma.collection.findMany({ orderBy: { name: 'asc' } });
const occasions = collections.filter((c) => c.group === 'OCCASION');
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({
searchParams,
}: {
searchParams: { error?: string };
searchParams: Promise<{ error?: string }>;
}) {
const { error } = await searchParams;
const activePromotions = await fetchActivePromotions();
let products = [];
let products: any[] = [];
try {
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()
);
} catch (err) {
@@ -35,7 +36,7 @@ export default async function AdminProductsPage({
</Link>
</div>
{searchParams.error === 'in_use' && (
{error === 'in_use' && (
<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.
</div>
+2 -2
View File
@@ -130,7 +130,7 @@ export async function POST(req: Request) {
// Calculate shipping cost (skip for collection orders)
let shippingCost = 0;
let selectedShippingService = shippingService;
let selectedShippingService: string | undefined | null = shippingService;
if (isCollection) {
console.log('Collection order - no shipping cost');
@@ -193,7 +193,7 @@ export async function POST(req: Request) {
email: guest?.email ?? customer?.email,
guestName: guest?.name,
guestPhone: guest?.phone,
isCollectionPickup,
isCollectionPickup: isCollection,
items: {
create: pricedItems.map((i) => ({
productId: i.productId,
+4 -3
View File
@@ -5,14 +5,15 @@ import { toProductDTO } from '@/lib/product';
export async function POST(
req: Request,
{ params }: { params: { id: string } }
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await req.json();
const { expectedDeliveryDate } = body;
const design = await prisma.designApproval.findUnique({
where: { id: params.id },
where: { id },
include: { product: true },
});
@@ -21,7 +22,7 @@ export async function POST(
}
const updated = await prisma.designApproval.update({
where: { id: params.id },
where: { id },
data: {
status: 'APPROVED',
expectedDeliveryDate: expectedDeliveryDate ? new Date(expectedDeliveryDate) : null,
+6 -4
View File
@@ -4,11 +4,12 @@ import type { DesignApprovalMessage } from '@prisma/client';
export async function GET(
req: Request,
{ params }: { params: { id: string } }
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const messages = await prisma.designApprovalMessage.findMany({
where: { designId: params.id },
where: { designId: id },
orderBy: { createdAt: 'asc' },
});
@@ -24,9 +25,10 @@ export async function GET(
export async function POST(
req: Request,
{ params }: { params: { id: string } }
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const { sender, body, role, message, messageType } = await req.json();
// 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({
data: {
designId: params.id,
designId: id,
sender: finalSender,
body: finalBody,
messageType: finalMessageType,
+4 -3
View File
@@ -3,9 +3,10 @@ import { prisma } from '@/lib/prisma';
export async function POST(
req: Request,
{ params }: { params: { id: string } }
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const {
designJson,
designPreviewUrl,
@@ -17,7 +18,7 @@ export async function POST(
} = await req.json();
const design = await prisma.designApproval.findUnique({
where: { id: params.id },
where: { id: id },
});
if (!design) {
@@ -25,7 +26,7 @@ export async function POST(
}
const updated = await prisma.designApproval.update({
where: { id: params.id },
where: { id: id },
data: {
...(designJson && { designJson }),
...(designPreviewUrl && { designPreviewUrl }),
+10 -7
View File
@@ -5,11 +5,12 @@ import { toProductDTO } from '@/lib/product';
export async function GET(
req: Request,
{ params }: { params: { id: string } }
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const design = await prisma.designApproval.findUnique({
where: { id: params.id },
where: { id },
include: { product: true, messages: { orderBy: { createdAt: 'asc' } } },
});
@@ -32,9 +33,10 @@ export async function GET(
export async function PATCH(
req: Request,
{ params }: { params: { id: string } }
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const customer = await getCurrentCustomer();
if (!customer) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
@@ -43,7 +45,7 @@ export async function PATCH(
const { status } = await req.json();
const updated = await prisma.designApproval.update({
where: { id: params.id },
where: { id },
data: { status },
include: { product: true, messages: { orderBy: { createdAt: 'asc' } } },
});
@@ -63,17 +65,18 @@ export async function PATCH(
export async function DELETE(
req: Request,
{ params }: { params: { id: string } }
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
// Delete associated messages first
await prisma.designApprovalMessage.deleteMany({
where: { designId: params.id },
where: { designId: id },
});
// Delete the design approval
await prisma.designApproval.delete({
where: { id: params.id },
where: { id: id },
});
return NextResponse.json({ success: true });
@@ -4,13 +4,14 @@ import { sendDesignReadyForReview } from '@/lib/mail';
export async function POST(
req: Request,
{ params }: { params: { id: string } }
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const { designPreviewUrl, designPreviewUrlBack, placementPreviewUrl, placementPreviewUrlBack } = await req.json().catch(() => ({}));
const design = await prisma.designApproval.findUnique({
where: { id: params.id },
where: { id: id },
include: { product: true },
});
@@ -26,7 +27,7 @@ export async function POST(
if (placementPreviewUrlBack) updateData.placementPreviewUrlBack = placementPreviewUrlBack;
const updated = await prisma.designApproval.update({
where: { id: params.id },
where: { id: id },
data: updateData,
include: { product: true, messages: { orderBy: { createdAt: 'asc' } } },
});
@@ -36,7 +37,7 @@ export async function POST(
customerName: design.customerName || 'Customer',
customerEmail: design.customerEmail,
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);
+3 -3
View File
@@ -1,9 +1,9 @@
import { NextResponse } from 'next/server';
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 {
const { id } = params;
const { id } = await params;
const order = await prisma.order.findUnique({
where: { id },
include: { items: true },
@@ -292,7 +292,7 @@ export async function GET(req: Request, { params }: { params: { id: string } })
${item.designImageDimensions ? (() => {
try {
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) {
return '';
}
@@ -4,11 +4,12 @@ import { fetchRoyalMailTracking, mapTrackingStatusToOrderStatus } from '@/lib/ro
export async function POST(
req: Request,
{ params }: { params: { id: string } }
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const order = await prisma.order.findUnique({
where: { id: params.id },
where: { id: id },
});
if (!order) {
@@ -31,13 +32,13 @@ export async function POST(
// Update order with new status
const updated = await prisma.order.update({
where: { id: params.id },
where: { id: id },
data: {
status: newStatus,
},
});
console.log(`✓ Order ${params.id} tracking synced: ${trackingData.status}${newStatus}`);
console.log(`✓ Order ${id} tracking synced: ${trackingData.status}${newStatus}`);
return NextResponse.json({
success: true,
+4 -3
View File
@@ -1,14 +1,15 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function POST(_req: Request, { params }: { params: { id: string } }) {
const proof = await prisma.designProof.findUnique({ where: { id: params.id } });
export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const proof = await prisma.designProof.findUnique({ where: { id } });
if (!proof) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
await prisma.designProof.update({
where: { id: params.id },
where: { id },
data: { status: 'APPROVED' },
});
+8 -6
View File
@@ -2,13 +2,14 @@ import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
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({
where: { proofId: params.id },
where: { proofId: id },
orderBy: { createdAt: 'asc' },
});
const messages: MessageDTO[] = rows.map((m) => ({
const messages: MessageDTO[] = rows.map((m: any) => ({
id: m.id,
proofId: m.proofId,
sender: m.sender as MessageDTO['sender'],
@@ -19,7 +20,8 @@ export async function GET(_req: Request, { params }: { params: { id: string } })
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();
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 });
}
const proof = await prisma.designProof.findUnique({ where: { id: params.id } });
const proof = await prisma.designProof.findUnique({ where: { id } });
if (!proof) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const created = await prisma.message.create({
data: { proofId: params.id, sender, body: trimmed },
data: { proofId: id, sender, body: trimmed },
});
const message: MessageDTO = {
+6 -4
View File
@@ -3,11 +3,12 @@ import { prisma } from '@/lib/prisma';
export async function DELETE(
req: Request,
{ params }: { params: { id: string } }
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const review = await prisma.review.delete({
where: { id: params.id },
where: { id: id },
});
return NextResponse.json(review);
@@ -22,14 +23,15 @@ export async function DELETE(
export async function PATCH(
req: Request,
{ params }: { params: { id: string } }
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await req.json();
const { approved } = body;
const review = await prisma.review.update({
where: { id: params.id },
where: { id: id },
data: { approved },
});
+1 -1
View File
@@ -15,7 +15,7 @@ export async function sendContactMessage(formData: FormData) {
redirect('/contact?error=missing');
}
const ip = getClientIp(headers());
const ip = getClientIp(await headers());
const turnstileToken = String(formData.get('cf-turnstile-response') ?? '');
const { success } = await verifyTurnstileToken(turnstileToken, ip);
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.');
}
const ip = getClientIp(headers());
const ip = getClientIp(await headers());
const turnstileToken = String(formData.get('cf-turnstile-response') ?? '');
const { success } = await verifyTurnstileToken(turnstileToken, ip);
if (!success) {
+49 -3
View File
@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useCart } from '@/lib/cartStore';
interface DesignApproval {
@@ -18,18 +19,23 @@ interface DesignApproval {
};
designJson: string;
designPreviewUrl: string;
designPreviewUrlBack: string | null;
placementPreviewUrl: string | null;
placementPreviewUrlBack: string | null;
color: string;
size: string | null;
status: string;
expectedDeliveryDate: string | null;
}
export default function DesignReviewPage({ params }: { params: { id: string } }) {
const router = useRouter();
const [design, setDesign] = useState<DesignApproval | null>(null);
const [loading, setLoading] = useState(true);
const [approving, setApproving] = useState(false);
const [changingMessage, setChangingMessage] = useState('');
const [sendingChanges, setSendingChanges] = useState(false);
const [showCheckoutOptions, setShowCheckoutOptions] = useState(false);
const addItem = useCart((s) => s.addItem);
useEffect(() => {
@@ -43,7 +49,11 @@ export default function DesignReviewPage({ params }: { params: { id: string } })
if (!design) return;
setApproving(true);
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) {
const updated = await res.json();
setDesign(updated);
@@ -59,7 +69,7 @@ export default function DesignReviewPage({ params }: { params: { id: string } })
size: design.size,
quantity: 1,
unitPrice: design.product.personalisedPrice ?? design.product.effectivePrice,
designJson: design.designJson,
designJson: JSON.parse(design.designJson),
designPreviewUrl: design.designPreviewUrl,
designPreviewUrlBack: design.designPreviewUrlBack,
placementPreviewUrl: design.placementPreviewUrl,
@@ -68,8 +78,13 @@ export default function DesignReviewPage({ params }: { params: { id: string } })
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 {
setApproving(false);
}
@@ -167,6 +182,11 @@ export default function DesignReviewPage({ params }: { params: { id: string } })
<p className="border-t border-line pt-3">
<strong>Price:</strong> £{((design.product.personalisedPrice ?? design.product.effectivePrice) / 100).toFixed(2)}
</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>
<button
@@ -197,6 +217,32 @@ export default function DesignReviewPage({ params }: { params: { id: string } })
</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>
);
}
+3 -2
View File
@@ -2,10 +2,11 @@ import Link from 'next/link';
import { prisma } from '@/lib/prisma';
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 activeSlug = searchParams.category ?? null;
const activeSlug = category ?? null;
const activeCategory = activeSlug ? categories.find((c) => c.slug === activeSlug) ?? null : null;
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 MaintenancePage from '@/components/MaintenancePage';
import LogoutOnUnload from '@/components/LogoutOnUnload';
import ThemeInitializer from '@/components/ThemeInitializer';
import { CurrencyProvider } from '@/lib/CurrencyContext';
import { getSiteSettings } from '@/lib/settings';
import { isAdminAuthenticated } from '@/lib/adminAuth';
@@ -31,7 +32,7 @@ export const metadata: Metadata = {
export default async function RootLayout({ children }: { children: React.ReactNode }) {
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
// reachable so the owner can log in and toggle it back off.
// 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"
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>
<body suppressHydrationWarning className="flex min-h-screen flex-col">
<ThemeInitializer />
<LogoutOnUnload />
<CurrencyProvider>
{showMaintenance ? (
+9 -9
View File
@@ -1,22 +1,22 @@
import { notFound } from 'next/navigation';
import dynamic from 'next/dynamic';
import { prisma } from '@/lib/prisma';
import { toProductDTO } from '@/lib/product';
import { fetchActivePromotions } from '@/lib/promotions';
import ProductCard from '@/components/ProductCard';
const DesignCanvas = dynamic(() => import('@/components/DesignCanvas'), { ssr: false });
import DesignCanvasWrapper from '@/components/DesignCanvasWrapper';
export default async function ProductDetailPage({
params,
searchParams,
}: {
params: { slug: string };
searchParams: { customRequestId?: string };
params: Promise<{ slug: string }>;
searchParams: Promise<{ customRequestId?: string }>;
}) {
const { slug } = await params;
const { customRequestId } = await searchParams;
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();
const product = toProductDTO(row, activePromotions, 'personalised');
@@ -29,9 +29,9 @@ export default async function ProductDetailPage({
// Fetch custom photo from request if customRequestId is provided
let customPhoto: string | undefined;
if (searchParams.customRequestId) {
if (customRequestId) {
const customRequest = await prisma.customRequest.findUnique({
where: { id: searchParams.customRequestId },
where: { id: customRequestId },
});
if (customRequest) {
customPhoto = customRequest.imageDataUrl;
@@ -40,7 +40,7 @@ export default async function ProductDetailPage({
return (
<div className="mx-auto max-w-6xl px-6 py-12">
<DesignCanvas product={product} customPhoto={customPhoto} />
<DesignCanvasWrapper product={product} customPhoto={customPhoto} />
{related.length > 0 && (
<section className="mt-20 border-t border-line pt-12">
+10 -229
View File
@@ -1,264 +1,46 @@
import { prisma } from '@/lib/prisma';
import { toProductDTO } from '@/lib/product';
import { fetchActivePromotions } from '@/lib/promotions';
import { getPersonalisedPaletteColors } from '@/lib/cache';
import ProductCard from '@/components/ProductCard';
import type { Prisma } from '@prisma/client';
type SearchParams = {
category?: string | string[];
collection?: string | string[];
color?: string | string[];
min?: string;
max?: string;
q?: string;
page?: string;
};
const PRODUCTS_PER_PAGE = 20;
function toArray(v?: string | string[]) {
if (!v) return [];
return Array.isArray(v) ? v : [v];
}
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) } : {}),
};
}
export default async function ProductsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const { page } = await searchParams;
const currentPage = Math.max(1, Number(page) || 1);
const activePromotions = await fetchActivePromotions();
const [rows, totalCount] = await Promise.all([
prisma.product.findMany({
where,
where: { showOnPersonalised: true },
orderBy: { createdAt: 'asc' },
take: 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 totalPages = Math.ceil(totalCount / PRODUCTS_PER_PAGE);
const hasFilters =
selectedCategories.length > 0 ||
selectedCollections.length > 0 ||
selectedColors.length > 0 ||
q ||
minDollars !== undefined ||
maxDollars !== undefined;
return (
<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>
<p className="tag-label">
{totalCount} template{totalCount === 1 ? '' : 's'}
</p>
</div>
<form method="GET" className="grid gap-10 md:grid-cols-[220px_1fr]">
{/* Sidebar filters */}
<aside className="space-y-8">
<div>
<label htmlFor="q" className="tag-label mb-2 block">
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>
<p className="tag-label mb-3">Category</p>
<div className="space-y-2">
{CATEGORIES.map((c) => (
<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>
{OCCASIONS.length > 0 && (
<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="/made-to-order" className="text-center text-sm text-muted hover:text-ink">
Clear filters
</a>
)}
</div>
</aside>
{/* 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>
<p className="font-display text-xl">Nothing here yet</p>
<p className="mt-2 text-sm text-muted">Templates will show up here once created.</p>
</div>
) : (
<div>
@@ -272,7 +54,7 @@ export default async function ProductsPage({ searchParams }: { searchParams: Sea
<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}` : ''}`}
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"
>
Previous
@@ -283,7 +65,7 @@ export default async function ProductsPage({ searchParams }: { searchParams: Sea
</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}` : ''}`}
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
@@ -294,7 +76,6 @@ export default async function ProductsPage({ searchParams }: { searchParams: Sea
</div>
)}
</div>
</form>
</div>
);
}
+7 -236
View File
@@ -1,120 +1,41 @@
import { prisma } from '@/lib/prisma';
import { toProductDTO } from '@/lib/product';
import { fetchActivePromotions } from '@/lib/promotions';
import { getPaletteColors } from '@/lib/cache';
import ProductCard from '@/components/ProductCard';
import type { Prisma } from '@prisma/client';
type SearchParams = {
category?: string | string[];
collection?: string | string[];
color?: string | string[];
min?: string;
max?: string;
q?: string;
page?: string;
};
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 }) {
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);
// 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 [rows, totalCount] = await Promise.all([
prisma.product.findMany({
where,
where: { showAsReadyMade: true },
orderBy: { createdAt: 'asc' },
take: 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 totalPages = Math.ceil(totalCount / PRODUCTS_PER_PAGE);
const hasFilters =
selectedCategories.length > 0 ||
selectedCollections.length > 0 ||
selectedColors.length > 0 ||
q ||
minDollars !== undefined ||
maxDollars !== undefined;
return (
<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>
<p className="tag-label">
{products.length} item{products.length === 1 ? '' : 's'}
{totalCount} item{totalCount === 1 ? '' : 's'}
</p>
</div>
{products.length === 0 && !hasFilters ? (
{products.length === 0 ? (
<div className="border border-line bg-surface p-12 text-center">
<p className="font-display text-xl">Nothing here yet</p>
<p className="mt-2 text-sm text-muted">
@@ -122,153 +43,6 @@ export default async function ReadyMadeProductsPage({ searchParams }: { searchPa
&quot;Products catalog&quot;.
</p>
</div>
) : (
<form method="GET" className="grid gap-10 md:grid-cols-[220px_1fr]">
{/* Sidebar filters */}
<aside className="space-y-8">
<div>
<label htmlFor="q" className="tag-label mb-2 block">
Search
</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>
<p className="tag-label mb-3">Category</p>
<div className="space-y-2">
{CATEGORIES.map((c) => (
<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>
{OCCASIONS.length > 0 && (
<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>
)}
</div>
</aside>
{/* 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">
@@ -281,7 +55,7 @@ export default async function ReadyMadeProductsPage({ searchParams }: { searchPa
<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}` : ''}`}
href={`/products?page=${currentPage - 1}`}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
Previous
@@ -292,7 +66,7 @@ export default async function ReadyMadeProductsPage({ searchParams }: { searchPa
</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}` : ''}`}
href={`/products?page=${currentPage + 1}`}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
Next
@@ -303,8 +77,5 @@ export default async function ReadyMadeProductsPage({ searchParams }: { searchPa
</div>
)}
</div>
</form>
)}
</div>
);
}
+1 -1
View File
@@ -2,7 +2,7 @@
import { useRef, useState, useEffect } from 'react';
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';
interface AdminDesignEditorProps {
+3 -1
View File
@@ -26,11 +26,13 @@ export default function ApproveSection({ proof }: { proof: ProofDTO }) {
size: null,
quantity: proof.quantity,
unitPrice: proof.unitPrice,
designJson: { front: [], back: [] },
designJson: { front: [], back: [] } as any,
designPreviewUrl: proof.imageDataUrl,
designPreviewUrlBack: null,
placementPreviewUrl: proof.imageDataUrl,
placementPreviewUrlBack: null,
designWidthCm: null,
designHeightCm: null,
});
setStatus('APPROVED');
router.push('/cart');
+24 -16
View File
@@ -325,12 +325,13 @@ function PrintSurface({
/>
{elements.map((el) =>
el.type === 'text' ? (
el.curvedPath ? (
(el as any).curvedPath ? (
// Curved text - TextPath with dragging via offset
(() => {
const elAny = el as any;
const offsetX = el.x - 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})`);
return (
<TextPath
@@ -773,10 +774,10 @@ export default function DesignCanvas({
salePrice: product.onSale ? product.effectivePrice : null,
saleStartsAt: null,
saleEndsAt: null,
},
} as any,
[],
new Date(),
true,
'personalised',
).price
: product.effectivePrice;
@@ -1251,10 +1252,10 @@ export default function DesignCanvas({
salePrice: product.onSale ? product.effectivePrice : null,
saleStartsAt: null,
saleEndsAt: null,
},
} as any,
[],
new Date(),
true,
'personalised',
);
return (
<>
@@ -1493,20 +1494,24 @@ export default function DesignCanvas({
<label className="flex items-center gap-2 text-sm text-muted mb-3">
<input
type="checkbox"
checked={text.curvedPath ? true : false}
onChange={(e) => updateElement(text.id, { curvedPath: e.target.checked ? { type: 'circle', radius: 80 } : null })}
checked={(text as any).curvedPath ? true : false}
onChange={(e) => updateElement(text.id, { curvedPath: e.target.checked ? { type: 'circle', radius: 80 } : null } as any)}
className="cursor-pointer"
/>
Curved text
</label>
{text.curvedPath && (
{(text as any).curvedPath && (
<div className="space-y-3">
{(() => {
const textAny = text as any;
return (
<>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted">Curve type</span>
<select
value={text.curvedPath.type || 'circle'}
onChange={(e) => updateElement(text.id, { curvedPath: { ...text.curvedPath, type: e.target.value as any } })}
value={textAny.curvedPath.type || 'circle'}
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"
>
<option value="circle">Circle</option>
@@ -1523,23 +1528,26 @@ export default function DesignCanvas({
type="range"
min={20}
max={200}
value={text.curvedPath.radius || 80}
onChange={(e) => updateElement(text.id, { curvedPath: { ...text.curvedPath, radius: Number(e.target.value) } })}
value={textAny.curvedPath.radius || 80}
onChange={(e) => updateElement(text.id, { curvedPath: { ...textAny.curvedPath, radius: Number(e.target.value) } } as any)}
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>
</label>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={text.curvedPath.reverse ? true : false}
onChange={(e) => updateElement(text.id, { curvedPath: { ...text.curvedPath, reverse: e.target.checked } })}
checked={textAny.curvedPath.reverse ? true : false}
onChange={(e) => updateElement(text.id, { curvedPath: { ...textAny.curvedPath, reverse: e.target.checked } } as any)}
className="cursor-pointer"
/>
<span className="text-muted">Reverse direction</span>
</label>
</>
);
})()}
</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 Link from 'next/link';
import NewsletterForm from './NewsletterForm';
export default function Footer() {
return (
<footer className="border-t border-line">
<footer suppressHydrationWarning className="border-t border-line">
<div className="h-1 w-full brand-gradient" />
<div className="mx-auto max-w-6xl px-6 py-10">
<div className="grid gap-8 md:grid-cols-4">
+4 -4
View File
@@ -30,8 +30,8 @@ export default async function Header() {
links: [
{ href: `/made-to-order?category=${c.slug}`, label: 'All' },
...c.children
.filter((ch) => ch.showOnPersonalised)
.map((child) => ({ href: `/made-to-order?category=${child.slug}`, label: child.name })),
.filter((ch: any) => ch.showOnPersonalised)
.map((child: any) => ({ href: `/made-to-order?category=${child.slug}`, label: child.name })),
],
})),
];
@@ -43,8 +43,8 @@ export default async function Header() {
links: [
{ href: `/products?category=${c.slug}`, label: 'All' },
...c.children
.filter((ch) => ch.showOnProducts)
.map((child) => ({ href: `/products?category=${child.slug}`, label: child.name })),
.filter((ch: any) => ch.showOnProducts)
.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"
>
<div className="flex gap-2">
<div className="flex gap-2" suppressHydrationWarning>
<input
suppressHydrationWarning
type="email"
required
value={email}
+9 -3
View File
@@ -11,10 +11,12 @@ export default function PhotoPrintAreaField({
variant = 'front',
existingImageUrl,
existingPrintArea,
colorPhotosPreview,
}: {
variant?: 'front' | 'back';
existingImageUrl?: string;
existingPrintArea?: Box;
colorPhotosPreview?: Record<string, string>;
}) {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [box, setBox] = useState<Box>(existingPrintArea || DEFAULT_BOX);
@@ -60,7 +62,9 @@ export default function PhotoPrintAreaField({
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) => {
e.preventDefault();
@@ -130,12 +134,14 @@ export default function PhotoPrintAreaField({
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
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 className="mt-1 text-xs text-muted">
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>
)}
+3 -1
View File
@@ -60,11 +60,13 @@ export default function StandardProductView({ product }: { product: ProductDTO }
size,
quantity,
unitPrice: product.effectivePrice,
designJson: { front: [], back: [] },
designJson: { front: [], back: [] } as any,
designPreviewUrl: product.colorPhotos[color] ?? product.imageUrl ?? '',
designPreviewUrlBack: product.colorPhotosBack[color] ?? product.imageUrlBack ?? null,
placementPreviewUrl: product.colorPhotos[color] ?? product.imageUrl ?? null,
placementPreviewUrlBack: product.colorPhotosBack[color] ?? product.imageUrlBack ?? null,
designWidthCm: null,
designHeightCm: null,
});
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';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import Script from 'next/script';
declare global {
@@ -33,9 +33,13 @@ export default function Turnstile() {
const containerRef = useRef<HTMLDivElement>(null);
const widgetIdRef = useRef<string | null>(null);
const [scriptReady, setScriptReady] = useState(false);
const readyRef = useRef(false);
useEffect(() => {
if (typeof window !== 'undefined' && window.turnstile) setScriptReady(true);
useLayoutEffect(() => {
if (typeof window !== 'undefined' && window.turnstile && !readyRef.current) {
readyRef.current = true;
setScriptReady(true);
}
}, []);
useEffect(() => {
@@ -68,7 +72,12 @@ export default function Turnstile() {
<Script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
strategy="afterInteractive"
onReady={() => setScriptReady(true)}
onLoad={() => {
if (!readyRef.current) {
readyRef.current = true;
setScriptReady(true);
}
}}
/>
<div ref={containerRef} />
</>
+4 -4
View File
@@ -23,7 +23,7 @@ export async function createAdminSession() {
.setExpirationTime(`${TOKEN_DURATION_SECONDS}s`)
.sign(adminSecretKey());
cookies().set(ADMIN_SESSION_COOKIE, token, {
(await cookies()).set(ADMIN_SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
@@ -31,12 +31,12 @@ export async function createAdminSession() {
});
}
export function clearAdminSession() {
cookies().delete(ADMIN_SESSION_COOKIE);
export async function clearAdminSession() {
(await cookies()).delete(ADMIN_SESSION_COOKIE);
}
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;
try {
+4 -4
View File
@@ -27,7 +27,7 @@ export async function createSession(customerId: string) {
.setExpirationTime(`${SESSION_DURATION_SECONDS}s`)
.sign(sessionSecretKey());
cookies().set(SESSION_COOKIE, token, {
(await cookies()).set(SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
@@ -36,12 +36,12 @@ export async function createSession(customerId: string) {
});
}
export function clearSession() {
cookies().delete(SESSION_COOKIE);
export async function clearSession() {
(await cookies()).delete(SESSION_COOKIE);
}
export async function getCurrentCustomer() {
const token = cookies().get(SESSION_COOKIE)?.value;
const token = (await cookies()).get(SESSION_COOKIE)?.value;
if (!token) return null;
try {
+4 -4
View File
@@ -26,8 +26,8 @@ function setCached<T>(key: string, data: T): void {
cache.set(key, { data, timestamp: Date.now() });
}
export async function getCategoriesWithChildren() {
const cached = getCached('categories_with_children');
export async function getCategoriesWithChildren(): Promise<any[]> {
const cached = getCached<any[]>('categories_with_children');
if (cached) return cached;
const categories = await prisma.category.findMany({
@@ -56,8 +56,8 @@ export async function getCategoriesWithChildren() {
return categories;
}
export async function getCollections() {
const cached = getCached('collections');
export async function getCollections(): Promise<any[]> {
const cached = getCached<any[]>('collections');
if (cached) return cached;
const collections = await prisma.collection.findMany({
+1 -1
View File
@@ -30,7 +30,7 @@ export async function logFinancialChange(
reason?: string
) {
try {
const ip = getClientIp(headers());
const ip = getClientIp(await headers());
await prisma.financialAuditLog.create({
data: {
+24 -6
View File
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -11,13 +15,27 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [{ "name": "next" }],
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
"@/*": [
"./src/*"
]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}