Initial commit: Craft2Prints with Stages 1-3

This commit is contained in:
Andymick
2026-07-15 18:09:59 +01:00
commit 41937ffc15
158 changed files with 16054 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
'use client';
import Link from 'next/link';
import { useEffect, useRef, useState } from 'react';
import { logoutCustomer } from '@/app/account/actions';
type AccountMenuProps = {
customer: { name: string | null; email: string } | null;
};
export default function AccountMenu({ customer }: AccountMenuProps) {
const [open, setOpen] = useState(false);
const [showLogoutConfirm, setShowLogoutConfirm] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const onClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', onClickOutside);
return () => document.removeEventListener('mousedown', onClickOutside);
}, []);
if (!customer) {
return (
<Link href="/account/login" className="tag-label hover:text-ink">
Login
</Link>
);
}
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="tag-label flex items-center gap-1 hover:text-ink"
>
{customer.name ?? 'Account'}
<span className={`inline-block transition-transform ${open ? 'rotate-180' : ''}`}></span>
</button>
{open && (
<div className="absolute right-0 top-full mt-2 w-48 border border-line bg-paper shadow-sm">
<Link href="/account" onClick={() => setOpen(false)} className="block px-4 py-3 text-sm hover:bg-surface">
My account
</Link>
<button
onClick={() => setShowLogoutConfirm(true)}
className="block w-full px-4 py-3 text-left text-sm hover:bg-surface"
>
Log out
</button>
</div>
)}
{showLogoutConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div className="w-96 border border-line bg-paper p-6 shadow-lg">
<h2 className="font-display text-lg">Log out?</h2>
<p className="mt-3 text-sm text-muted">
Your cart will be saved. You&apos;ll need to log in again to complete your order.
</p>
<div className="mt-6 flex gap-3">
<button
onClick={() => setShowLogoutConfirm(false)}
className="flex-1 border border-line px-4 py-2 text-sm hover:bg-surface"
>
Cancel
</button>
<form action={logoutCustomer} className="flex-1">
<button type="submit" className="w-full bg-clay px-4 py-2 text-sm font-medium text-paper hover:bg-clay-dark">
Log out
</button>
</form>
</div>
</div>
</div>
)}
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import Link from 'next/link';
const TABS = [
{ href: '/admin/accounts', label: 'Ledger' },
{ href: '/admin/accounts/stock', label: 'Stock' },
{ href: '/admin/accounts/purchases', label: 'Purchases' },
] as const;
export default function AccountsNav({ active }: { active: (typeof TABS)[number]['href'] }) {
return (
<div className="mt-4 inline-flex border border-line">
{TABS.map((tab) => (
<Link
key={tab.href}
href={tab.href}
className={`px-4 py-1.5 text-sm ${active === tab.href ? 'bg-ink text-paper' : 'hover:text-clay'}`}
>
{tab.label}
</Link>
))}
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
'use client';
import Link from 'next/link';
import { useEffect, useRef, useState } from 'react';
import { logoutAdmin } from '@/app/admin/login/actions';
const ADMIN_LINKS = [
{ href: '/admin/orders', label: 'Orders' },
{ href: '/admin/accounts', label: 'Accounts' },
{ href: '/admin/products/new', label: 'Add product' },
{ href: '/admin/products', label: 'All products' },
{ href: '/admin/categories/new', label: 'Add category' },
{ href: '/admin/categories', label: 'All categories' },
{ href: '/admin/proofs/new', label: 'Upload new design' },
{ href: '/admin/proofs', label: 'All proofs' },
{ href: '/admin/inbox', label: 'Inbox' },
{ href: '/admin/custom-requests', label: 'Photo requests' },
{ href: '/admin/promotions', label: 'Promotions' },
{ href: '/admin/gallery', label: 'Gallery' },
{ href: '/admin/gallery/categories', label: 'Gallery categories' },
{ href: '/admin/collections/new', label: 'Add collection' },
{ href: '/admin/collections', label: 'All collections' },
{ href: '/admin/customers', label: 'Email customers' },
{ href: '/admin/maintenance', label: 'Maintenance' },
];
export default function AdminMenu() {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const onClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', onClickOutside);
return () => document.removeEventListener('mousedown', onClickOutside);
}, []);
return (
<div ref={ref} className="relative hidden md:block">
<button
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="tag-label flex items-center gap-1 hover:text-ink"
>
Admin
<span className={`inline-block transition-transform ${open ? 'rotate-180' : ''}`}></span>
</button>
{open && (
<div className="absolute right-0 top-full mt-2 w-48 border border-line bg-paper shadow-sm">
{ADMIN_LINKS.map((link) => (
<Link
key={link.href}
href={link.href}
onClick={() => setOpen(false)}
className="block px-4 py-3 text-sm hover:bg-surface"
>
{link.label}
</Link>
))}
<form action={logoutAdmin}>
<button type="submit" className="block w-full px-4 py-3 text-left text-sm hover:bg-surface">
Log out
</button>
</form>
</div>
)}
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import Link from 'next/link';
export default function AdminNav() {
return (
<div className="mb-8 flex flex-wrap gap-6 border-b border-line pb-4">
<Link href="/admin/orders" className="tag-label hover:text-ink">
Orders
</Link>
<Link href="/admin/products/new" className="tag-label hover:text-ink">
Add product
</Link>
<Link href="/admin/products" className="tag-label hover:text-ink">
All products
</Link>
<Link href="/admin/categories/new" className="tag-label hover:text-ink">
Add category
</Link>
<Link href="/admin/categories" className="tag-label hover:text-ink">
All categories
</Link>
<Link href="/admin/proofs/new" className="tag-label hover:text-ink">
Upload new design
</Link>
<Link href="/admin/proofs" className="tag-label hover:text-ink">
All proofs
</Link>
<Link href="/admin/inbox" className="tag-label hover:text-ink">
Inbox
</Link>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { v4 as uuid } from 'uuid';
import { useCart } from '@/lib/cartStore';
import type { ProofDTO } from '@/lib/types';
export default function ApproveSection({ proof }: { proof: ProofDTO }) {
const [status, setStatus] = useState<ProofDTO['status']>(proof.status);
const [loading, setLoading] = useState(false);
const addItem = useCart((s) => s.addItem);
const router = useRouter();
const approve = async () => {
setLoading(true);
try {
await fetch(`/api/proofs/${proof.id}/approve`, { method: 'POST' });
addItem({
cartItemId: uuid(),
productId: proof.productId,
slug: proof.productSlug,
name: proof.productName,
mockup: '',
color: proof.color,
size: null,
quantity: proof.quantity,
unitPrice: proof.unitPrice,
designJson: { front: [], back: [] },
designPreviewUrl: proof.imageDataUrl,
designPreviewUrlBack: null,
placementPreviewUrl: proof.imageDataUrl,
placementPreviewUrlBack: null,
});
setStatus('APPROVED');
router.push('/cart');
} finally {
setLoading(false);
}
};
if (status === 'APPROVED') {
return (
<div className="border border-splash-blue bg-splash-blue/5 p-4 text-sm text-ink">
Approved this design is in your cart.
</div>
);
}
return (
<button
onClick={approve}
disabled={loading}
className="w-full bg-clay px-6 py-3 text-sm font-medium text-paper transition-colors hover:bg-clay-dark disabled:opacity-60"
>
{loading ? 'Adding to cart…' : 'Approve & add to bag'}
</button>
);
}
+25
View File
@@ -0,0 +1,25 @@
'use client';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { useCart } from '@/lib/cartStore';
export default function CartButton() {
// Avoid hydration mismatch: item count only read after mount.
const [mounted, setMounted] = useState(false);
const items = useCart((s) => s.items);
useEffect(() => setMounted(true), []);
const count = mounted ? items.reduce((n, i) => n + i.quantity, 0) : 0;
return (
<Link
href="/cart"
className="group flex items-center gap-2 border border-ink px-4 py-2 text-sm transition-colors hover:border-clay hover:bg-clay hover:text-paper"
>
<span className="font-mono">Bag</span>
<span className="font-mono tabular-nums">({count})</span>
</Link>
);
}
+126
View File
@@ -0,0 +1,126 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import type { MessageDTO } from '@/lib/types';
const POLL_MS = 4000;
function formatTime(iso: string) {
return new Date(iso).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
export default function Chat({
proofId,
role,
otherPartyLabel,
}: {
proofId: string;
role: 'ADMIN' | 'CUSTOMER';
otherPartyLabel: string;
}) {
const [messages, setMessages] = useState<MessageDTO[]>([]);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const res = await fetch(`/api/proofs/${proofId}/messages`);
if (!res.ok || cancelled) return;
const data: MessageDTO[] = await res.json();
if (!cancelled) setMessages(data);
} catch {
// silently retry on next poll
}
};
load();
const interval = setInterval(load, POLL_MS);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [proofId]);
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
}, [messages.length]);
const send = async () => {
const trimmed = input.trim();
if (!trimmed || sending) return;
setSending(true);
setInput('');
try {
const res = await fetch(`/api/proofs/${proofId}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sender: role, body: trimmed }),
});
if (res.ok) {
const created: MessageDTO = await res.json();
setMessages((prev) => [...prev, created]);
}
} finally {
setSending(false);
}
};
return (
<div className="border border-line bg-surface">
<div className="border-b border-line px-4 py-3">
<p className="tag-label">Chat with {otherPartyLabel}</p>
</div>
<div ref={scrollRef} className="max-h-72 space-y-3 overflow-y-auto p-4">
{messages.length === 0 ? (
<p className="text-sm text-muted">No messages yet say hello.</p>
) : (
messages.map((m) => {
const mine = m.sender === role;
return (
<div key={m.id} className={`flex ${mine ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[75%] ${mine ? 'bg-clay text-paper' : 'bg-paper border border-line text-ink'} px-3 py-2`}>
<p className="text-sm">{m.body}</p>
<p className={`mt-1 text-[10px] ${mine ? 'text-paper/60' : 'text-muted'}`}>
{formatTime(m.createdAt)}
</p>
</div>
</div>
);
})
)}
</div>
<div className="flex gap-2 border-t border-line p-3">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
send();
}
}}
placeholder="Type a message…"
className="flex-1 border border-line px-3 py-2 text-sm"
/>
<button
onClick={send}
disabled={sending || !input.trim()}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper disabled:opacity-50"
>
Send
</button>
</div>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
// A small decorative 5-petal blossom — plain flat SVG, no assets, matches the
// hand-drawn look of ProductMockup.tsx. Purely decorative: aria-hidden, no
// interactivity needed.
export default function CherryBlossom({
className = '',
size = 32,
style,
}: {
className?: string;
size?: number;
style?: React.CSSProperties;
}) {
return (
<svg viewBox="0 0 40 40" width={size} height={size} className={className} style={style} aria-hidden>
<g fill="#E5227E">
{[0, 72, 144, 216, 288].map((angle) => (
<ellipse key={angle} cx="20" cy="11" rx="5.5" ry="8" transform={`rotate(${angle} 20 20)`} />
))}
</g>
<circle cx="20" cy="20" r="2.5" fill="#F5871F" />
</svg>
);
}
+12
View File
@@ -0,0 +1,12 @@
'use client';
import { useEffect } from 'react';
import { useCart } from '@/lib/cartStore';
export default function ClearCartOnMount() {
const clear = useCart((s) => s.clear);
useEffect(() => {
clear();
}, [clear]);
return null;
}
+313
View File
@@ -0,0 +1,313 @@
'use client';
import { useEffect, useRef, useState } from 'react';
function frontFieldName(hex: string) {
return `colorPhoto__${hex.replace('#', '')}`;
}
function backFieldName(hex: string) {
return `colorPhotoBack__${hex.replace('#', '')}`;
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new window.Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
}
// Samples the photo on an offscreen canvas and averages the non-background
// pixels — product photos are typically shot on a plain white/light backdrop,
// so pixels that are both very light and low-saturation (the backdrop) are
// excluded, leaving mostly garment pixels to average into one hex color.
async function detectDominantColor(file: File): Promise<string> {
const objectUrl = URL.createObjectURL(file);
try {
const img = await loadImage(objectUrl);
const size = 120;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) return '#808080';
ctx.drawImage(img, 0, 0, size, size);
const { data } = ctx.getImageData(0, 0, size, size);
let r = 0;
let g = 0;
let b = 0;
let count = 0;
for (let i = 0; i < data.length; i += 4) {
const pr = data[i];
const pg = data[i + 1];
const pb = data[i + 2];
const pa = data[i + 3];
if (pa < 200) continue;
const max = Math.max(pr, pg, pb);
const min = Math.min(pr, pg, pb);
const lightness = (max + min) / 2 / 255;
const sat = max === min ? 0 : (max - min) / (255 - Math.abs(max + min - 255));
if (lightness > 0.92 && sat < 0.12) continue; // near-white/gray backdrop
r += pr;
g += pg;
b += pb;
count += 1;
}
if (count === 0) {
r = data[0];
g = data[1];
b = data[2];
count = 1;
}
r = Math.round(r / count);
g = Math.round(g / count);
b = Math.round(b / count);
return `#${[r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('')}`;
} finally {
URL.revokeObjectURL(objectUrl);
}
}
function hexDistance(a: string, b: string) {
const [ar, ag, ab] = [1, 3, 5].map((i) => parseInt(a.slice(i, i + 2), 16));
const [br, bg, bb] = [1, 3, 5].map((i) => parseInt(b.slice(i, i + 2), 16));
return Math.sqrt((ar - br) ** 2 + (ag - bg) ** 2 + (ab - bb) ** 2);
}
// Snaps a freshly-detected color onto an existing one if they're close
// (different lighting/JPEG noise shouldn't produce two near-identical
// swatches for what's really the same shirt color).
function findNearbyColor(hex: string, existing: string[], threshold = 30) {
return existing.find((c) => hexDistance(c, hex) < threshold) ?? null;
}
type BulkResult = { filename: string; hex: string; variant: 'front' | 'back' };
export default function ColorPicker({
name,
defaultColors = [],
defaultColorPhotos = {},
defaultColorPhotosBack = {},
}: {
name: string;
defaultColors?: string[];
defaultColorPhotos?: Record<string, string>;
defaultColorPhotosBack?: Record<string, string>;
}) {
const [colors, setColors] = useState<string[]>(defaultColors.length ? defaultColors : ['#17181C']);
const [picked, setPicked] = useState('#1EA7E0');
// Local previews for newly-picked files, so admins see what they just attached.
const [previews, setPreviews] = useState<Record<string, string>>({});
const [previewsBack, setPreviewsBack] = useState<Record<string, string>>({});
const bulkInputRef = useRef<HTMLInputElement | null>(null);
const fileInputRefs = useRef<Map<string, HTMLInputElement>>(new Map());
const pendingFilesRef = useRef<Map<string, File>>(new Map());
const [bulkBusy, setBulkBusy] = useState(false);
const [bulkResults, setBulkResults] = useState<BulkResult[]>([]);
const addColor = () => {
if (colors.includes(picked)) return;
setColors((prev) => [...prev, picked]);
};
const removeColor = (c: string) => {
setColors((prev) => prev.filter((x) => x !== c));
};
const handlePhotoChange = (hex: string, file: File | null, back: boolean) => {
const setter = back ? setPreviewsBack : setPreviews;
if (!file) {
setter((prev) => {
const next = { ...prev };
delete next[hex];
return next;
});
return;
}
setter((prev) => ({ ...prev, [hex]: URL.createObjectURL(file) }));
};
// Once a pending bulk-detected file's color has a rendered <input> (i.e. its
// color row just mounted), push the File into that native input via
// DataTransfer and fire the same change handler a manual pick would — so
// the preview and the real file both update, and the form submits it
// exactly like any other per-color upload.
useEffect(() => {
if (pendingFilesRef.current.size === 0) return;
for (const [key, file] of pendingFilesRef.current) {
const input = fileInputRefs.current.get(key);
if (!input) continue;
const dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
const [hex, variant] = key.split('__');
handlePhotoChange(hex, file, variant === 'back');
pendingFilesRef.current.delete(key);
}
}, [colors]);
const handleBulkFiles = async (files: FileList | null) => {
if (!files || files.length === 0) return;
setBulkBusy(true);
setBulkResults([]);
const results: BulkResult[] = [];
const nextColors = [...colors];
for (const file of Array.from(files)) {
if (!file.type.startsWith('image/')) continue;
const variant: 'front' | 'back' = /back/i.test(file.name) ? 'back' : 'front';
const detected = await detectDominantColor(file);
const hex = findNearbyColor(detected, nextColors) ?? detected;
if (!nextColors.includes(hex)) nextColors.push(hex);
const key = `${hex}__${variant}`;
pendingFilesRef.current.set(key, file);
results.push({ filename: file.name, hex, variant });
}
setColors(nextColors);
setBulkResults(results);
setBulkBusy(false);
if (bulkInputRef.current) bulkInputRef.current.value = '';
};
return (
<div>
<input type="hidden" name={name} value={colors.join(',')} />
<div className="mb-4 border border-line bg-surface p-3">
<p className="tag-label mb-1">Bulk upload from a folder</p>
<p className="mb-2 text-xs text-muted">
Point at a folder of T-shirt photos and each one is scanned to detect the shirt&apos;s
color and added automatically. Name a file with &quot;back&quot; in it (e.g.{' '}
<code>navy-back.jpg</code>) to attach it as that color&apos;s back photo instead of front.
</p>
<input
ref={bulkInputRef}
type="file"
accept="image/*"
multiple
// @ts-expect-error non-standard attributes for folder selection, supported in Chromium/Firefox
webkitdirectory=""
directory=""
onChange={(e) => handleBulkFiles(e.target.files)}
className="text-xs"
/>
{bulkBusy && <p className="mt-2 text-xs text-muted">Scanning images</p>}
{bulkResults.length > 0 && !bulkBusy && (
<div className="mt-3 space-y-1">
{bulkResults.map((r, i) => (
<div key={`${r.filename}-${i}`} className="flex items-center gap-2 text-xs">
<div className="h-4 w-4 shrink-0 rounded-full border border-line" style={{ backgroundColor: r.hex }} />
<span className="font-mono text-muted">{r.hex}</span>
<span className="truncate text-muted">{r.filename}</span>
<span className="tag-label shrink-0 text-muted">{r.variant}</span>
</div>
))}
</div>
)}
</div>
<div className="flex flex-wrap gap-3">
{colors.map((c) => (
<div key={c} className="group relative">
<div
className="h-10 w-10 rounded-full border border-line"
style={{ backgroundColor: c }}
title={c}
/>
<button
type="button"
onClick={() => removeColor(c)}
aria-label={`Remove ${c}`}
className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full border border-line bg-paper text-xs opacity-0 transition-opacity group-hover:opacity-100 hover:border-splash-pink hover:text-splash-pink"
>
×
</button>
</div>
))}
</div>
<div className="mt-3 flex items-center gap-3">
<input
type="color"
value={picked}
onChange={(e) => setPicked(e.target.value)}
className="h-9 w-9 cursor-pointer border border-line bg-paper p-0"
/>
<span className="font-mono text-xs text-muted">{picked}</span>
<button
type="button"
onClick={addColor}
className="border border-ink px-3 py-1.5 text-xs hover:border-clay hover:bg-clay hover:text-paper"
>
+ Add color
</button>
</div>
{colors.length === 0 && <p className="mt-2 text-xs text-splash-pink">Add at least one color.</p>}
{colors.length > 0 && (
<div className="mt-5 border-t border-line pt-4">
<p className="tag-label mb-1">Photo per color (optional)</p>
<p className="mb-3 text-xs text-muted">
Attach the real front and/or back photo for a color and it&apos;ll be shown exactly as-is
when a customer picks that color more accurate than tinting one photo. Leave either
blank to fall back to the product&apos;s main front/back photo (or the illustration).
</p>
<div className="space-y-3">
{colors.map((hex) => {
const preview = previews[hex] ?? defaultColorPhotos[hex];
const previewBack = previewsBack[hex] ?? defaultColorPhotosBack[hex];
return (
<div key={hex} className="border border-line p-2">
<div className="mb-2 flex items-center gap-2">
<div className="h-6 w-6 shrink-0 rounded-full border border-line" style={{ backgroundColor: hex }} />
<span className="font-mono text-xs text-muted">{hex}</span>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<div className="flex items-center gap-2">
<span className="tag-label w-10 shrink-0">Front</span>
{preview && (
<img src={preview} alt={`${hex} front preview`} className="h-9 w-9 shrink-0 border border-line object-cover" />
)}
<input
ref={(el) => {
if (el) fileInputRefs.current.set(`${hex}__front`, el);
}}
type="file"
accept="image/*"
name={frontFieldName(hex)}
onChange={(e) => handlePhotoChange(hex, e.target.files?.[0] ?? null, false)}
className="min-w-0 flex-1 text-xs"
/>
</div>
<div className="flex items-center gap-2">
<span className="tag-label w-10 shrink-0">Back</span>
{previewBack && (
<img src={previewBack} alt={`${hex} back preview`} className="h-9 w-9 shrink-0 border border-line object-cover" />
)}
<input
ref={(el) => {
if (el) fileInputRefs.current.set(`${hex}__back`, el);
}}
type="file"
accept="image/*"
name={backFieldName(hex)}
onChange={(e) => handlePhotoChange(hex, e.target.files?.[0] ?? null, true)}
className="min-w-0 flex-1 text-xs"
/>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
'use client';
import { useState } from 'react';
export default function CopyLink({ link }: { link: string }) {
const [copied, setCopied] = useState(false);
const copy = async () => {
await navigator.clipboard.writeText(link);
setCopied(true);
setTimeout(() => setCopied(false), 1800);
};
return (
<div className="mt-6 flex items-center gap-2">
<input
readOnly
value={link}
onFocus={(e) => e.target.select()}
className="w-full border border-line px-3 py-2 font-mono text-xs"
/>
<button
onClick={copy}
className="whitespace-nowrap border border-ink px-4 py-2 text-sm hover:bg-ink hover:text-paper"
>
{copied ? 'Copied' : 'Copy link'}
</button>
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useCurrency } from '@/lib/CurrencyContext';
import { CURRENCIES } from '@/lib/currency';
export default function CurrencySelector() {
const { currency, setCurrency } = useCurrency();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const onClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', onClickOutside);
return () => document.removeEventListener('mousedown', onClickOutside);
}, []);
const active = CURRENCIES.find((c) => c.code === currency) ?? CURRENCIES[0];
return (
<div ref={ref} className="relative hidden md:block">
<button
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="tag-label flex items-center gap-1 hover:text-ink"
>
{active.symbol} {active.code}
<span className={`inline-block transition-transform ${open ? 'rotate-180' : ''}`}></span>
</button>
{open && (
<div className="absolute right-0 top-full mt-2 w-44 border border-line bg-paper shadow-sm">
{CURRENCIES.map((c) => (
<button
key={c.code}
onClick={() => {
setCurrency(c.code);
setOpen(false);
}}
className={`block w-full px-4 py-3 text-left text-sm hover:bg-surface ${
c.code === currency ? 'text-clay' : ''
}`}
>
{c.symbol} {c.label}
</button>
))}
</div>
)}
</div>
);
}
+866
View File
@@ -0,0 +1,866 @@
'use client';
import { useRef, useState, useEffect, useLayoutEffect, useCallback } from 'react';
import { Stage, Layer, Text, Image as KonvaImage, Transformer, Rect } from 'react-konva';
import type Konva from 'konva';
import { v4 as uuid } from 'uuid';
import ProductMockup from './ProductMockup';
import Price from './Price';
import { useCart } from '@/lib/cartStore';
import { getEffectivePrice } from '@/lib/pricing';
import type { DesignElement, PrintArea, ProductDTO } from '@/lib/types';
const DISPLAY = 560; // css px size the mockup + stage are rendered at
const CAPTURE_SCALE = 2; // matches pixelRatio used for exports, for crisp downloads
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new window.Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
}
// Draws `img` into the canvas region like CSS `object-fit: cover` would.
function drawCover(ctx: CanvasRenderingContext2D, img: HTMLImageElement, dw: number, dh: number) {
const imgRatio = img.width / img.height;
const boxRatio = dw / dh;
let sx: number, sy: number, sw: number, sh: number;
if (imgRatio > boxRatio) {
sh = img.height;
sw = sh * boxRatio;
sx = (img.width - sw) / 2;
sy = 0;
} else {
sw = img.width;
sh = sw / boxRatio;
sx = 0;
sy = (img.height - sh) / 2;
}
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, dw, dh);
}
// Builds a reference image showing the design placed on the actual product photo
// (garment + artwork composited together) — separate from the clean, print-ready,
// transparent-background export. Returns null if there's no real photo to composite
// onto (illustration-only products skip this; there's nothing photographic to show).
async function composePlacementImage(
photoUrl: string,
recolor: { tint: boolean; color: string } | null,
stage: Konva.Stage,
box: { left: number; top: number; w: number; h: number },
): Promise<string> {
const canvas = document.createElement('canvas');
canvas.width = DISPLAY * CAPTURE_SCALE;
canvas.height = DISPLAY * CAPTURE_SCALE;
const ctx = canvas.getContext('2d');
if (!ctx) return '';
const photoImg = await loadImage(photoUrl);
if (recolor?.tint) {
ctx.filter = 'grayscale(1) brightness(2.1) contrast(1.15)';
drawCover(ctx, photoImg, canvas.width, canvas.height);
ctx.filter = 'none';
ctx.globalCompositeOperation = 'multiply';
ctx.fillStyle = recolor.color;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.globalCompositeOperation = 'source-over';
} else {
drawCover(ctx, photoImg, canvas.width, canvas.height);
}
const designCanvas = stage.toCanvas({ pixelRatio: CAPTURE_SCALE });
ctx.drawImage(
designCanvas,
box.left * CAPTURE_SCALE,
box.top * CAPTURE_SCALE,
box.w * CAPTURE_SCALE,
box.h * CAPTURE_SCALE,
);
return canvas.toDataURL('image/png');
}
const FONTS = [
'Inter',
'Fraunces',
'Caveat',
'Playfair Display',
'Oswald',
'Bebas Neue',
'Pacifico',
'Permanent Marker',
'JetBrains Mono',
'Georgia',
'Arial',
];
function UploadedImage({
el,
onSelect,
onChange,
shapeRef,
}: {
el: Extract<DesignElement, { type: 'image' }>;
onSelect: () => void;
onChange: (attrs: Partial<DesignElement>) => void;
shapeRef: (node: Konva.Image | null) => void;
}) {
const [img, setImg] = useState<HTMLImageElement | null>(null);
useEffect(() => {
const image = new window.Image();
image.src = el.src;
image.onload = () => setImg(image);
}, [el.src]);
if (!img) return null;
return (
<KonvaImage
ref={shapeRef}
image={img}
x={el.x}
y={el.y}
width={el.width}
height={el.height}
rotation={el.rotation}
draggable
listening={true}
onClick={onSelect}
onTap={onSelect}
onDragEnd={(e) => onChange({ x: e.target.x(), y: e.target.y() })}
onTransformEnd={(e) => {
const node = e.target;
onChange({
x: node.x(),
y: node.y(),
rotation: node.rotation(),
width: Math.max(10, node.width() * node.scaleX()),
height: Math.max(10, node.height() * node.scaleY()),
});
node.scaleX(1);
node.scaleY(1);
}}
/>
);
}
// One print surface (photo + the draggable design layer on top). Front and back
// each get their own instance, both always mounted (just shown/hidden with CSS)
// so their Konva stages stay exportable even when you're looking at the other side.
function PrintSurface({
photo,
printArea,
elements,
selectedId,
setSelectedId,
updateElement,
nodeRefs,
stageRef,
transformerRef,
visible,
caption,
onNodeReady,
scale,
}: {
photo: React.ReactNode;
printArea: PrintArea;
elements: DesignElement[];
selectedId: string | null;
setSelectedId: (id: string | null) => void;
updateElement: (id: string, attrs: Partial<DesignElement>) => void;
nodeRefs: React.MutableRefObject<Map<string, Konva.Node>>;
stageRef: React.RefObject<Konva.Stage>;
transformerRef: React.RefObject<Konva.Transformer>;
visible: boolean;
caption: string;
onNodeReady: () => void;
scale: number;
}) {
const stageW = printArea.wPct * DISPLAY;
const stageH = printArea.hPct * DISPLAY;
const stageLeft = printArea.xPct * DISPLAY;
const stageTop = printArea.yPct * DISPLAY;
return (
<div className={visible ? '' : 'hidden'}>
{/* Konva's Stage stays a fixed 560x560 internally so element coordinates,
print-area math, and export resolution never change — only the visual
box shrinks via CSS transform to fit narrow viewports. */}
<div style={{ width: DISPLAY * scale, height: DISPLAY * scale }}>
<div
className="relative border border-line bg-surface"
style={{ width: DISPLAY, height: DISPLAY, transform: `scale(${scale})`, transformOrigin: 'top left' }}
>
<div className="pointer-events-none absolute inset-0" style={{ width: DISPLAY, height: DISPLAY }}>
{photo}
</div>
<div className="absolute" style={{ left: stageLeft, top: stageTop, width: stageW, height: stageH }}>
<Stage
ref={stageRef}
width={stageW}
height={stageH}
onMouseDown={(e) => {
if (e.target === e.target.getStage()) setSelectedId(null);
}}
>
<Layer>
<Rect
name="printGuide"
x={0}
y={0}
width={stageW}
height={stageH}
stroke="#E5227E"
dash={[10, 6]}
strokeWidth={3}
shadowColor="#E5227E"
shadowBlur={12}
shadowOpacity={0.5}
listening={false}
/>
<Rect
name="printGuide"
x={0}
y={0}
width={stageW}
height={stageH}
fill="#E5227E"
opacity={0.07}
listening={false}
/>
{elements.map((el) =>
el.type === 'text' ? (
<Text
key={el.id}
ref={(node) => {
if (node) nodeRefs.current.set(el.id, node);
}}
x={el.x}
y={el.y}
text={el.text}
fontFamily={el.fontFamily}
fontSize={el.fontSize}
fill={el.fill}
rotation={el.rotation}
draggable
listening={true}
onClick={() => setSelectedId(el.id)}
onTap={() => setSelectedId(el.id)}
onDragEnd={(e) => updateElement(el.id, { x: e.target.x(), y: e.target.y() })}
onTransformEnd={(e) => {
const node = e.target;
updateElement(el.id, {
x: node.x(),
y: node.y(),
rotation: node.rotation(),
fontSize: Math.max(8, el.fontSize * node.scaleX()),
});
node.scaleX(1);
node.scaleY(1);
}}
/>
) : (
<UploadedImage
key={el.id}
el={el}
onSelect={() => setSelectedId(el.id)}
onChange={(attrs) => updateElement(el.id, attrs)}
shapeRef={(node) => {
if (node && nodeRefs.current.get(el.id) !== node) {
nodeRefs.current.set(el.id, node);
onNodeReady();
}
}}
/>
),
)}
<Transformer
ref={transformerRef}
rotateEnabled
flipEnabled={false}
rotateAnchorOffset={30}
anchorCornerRadius={4}
anchorSize={8}
borderStroke="#17181C"
borderStrokeWidth={2}
anchorStroke="#17181C"
anchorFill="#FFF"
boundBoxFunc={(oldBox, newBox) => (newBox.width < 8 || newBox.height < 8 ? oldBox : newBox)}
/>
</Layer>
</Stage>
</div>
</div>
</div>
<p className="tag-label mt-3">{caption}</p>
</div>
);
}
export default function DesignCanvas({ product }: { product: ProductDTO }) {
const [color, setColor] = useState(product.colors[0]);
const [size, setSize] = useState<string | null>(product.sizes[0] ?? null);
const [view, setView] = useState<'front' | 'back'>('front');
const [elementsFront, setElementsFront] = useState<DesignElement[]>([]);
const [elementsBack, setElementsBack] = useState<DesignElement[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [quantity, setQuantity] = useState(1);
const [justAdded, setJustAdded] = useState(false);
const [nodeReadyTick, setNodeReadyTick] = useState(0);
const stageFrontRef = useRef<Konva.Stage | null>(null);
const stageBackRef = useRef<Konva.Stage | null>(null);
const transformerFrontRef = useRef<Konva.Transformer | null>(null);
const transformerBackRef = useRef<Konva.Transformer | null>(null);
const nodeRefs = useRef<Map<string, Konva.Node>>(new Map());
const fileInputRef = useRef<HTMLInputElement | null>(null);
const canvasContainerRef = useRef<HTMLDivElement | null>(null);
const [canvasScale, setCanvasScale] = useState(1);
const addItem = useCart((s) => s.addItem);
// Konva's Stage keeps a fixed 560x560 internal pixel size (element x/y,
// print-area math, and export resolution all key off DISPLAY) — on narrow
// viewports we shrink it purely visually via CSS transform instead of
// touching that coordinate system. Konva reads the container's actual
// rendered (post-transform) bounding box for pointer math, so clicks/drags
// still line up correctly at any scale.
useLayoutEffect(() => {
const el = canvasContainerRef.current;
if (!el) return;
const update = () => setCanvasScale(Math.min(1, el.clientWidth / DISPLAY));
update();
const observer = new ResizeObserver(update);
observer.observe(el);
return () => observer.disconnect();
}, []);
const hasBack = Boolean(product.imageUrlBack);
const canPersonalize = product.showOnPersonalised;
const frontPrintArea = product.printArea;
const backPrintArea = product.printAreaBack ?? product.printArea;
const activePrintArea = view === 'front' ? frontPrintArea : backPrintArea;
const activeStageW = activePrintArea.wPct * DISPLAY;
const activeStageH = activePrintArea.hPct * DISPLAY;
const setActiveElements = view === 'front' ? setElementsFront : setElementsBack;
// Keep whichever transformer actually owns the selected element in sync, and
// clear the other one — an id only ever lives in one of the two arrays.
// nodeReadyTick re-runs this once an uploaded image's Konva node actually
// mounts (it loads asynchronously, unlike text, so it isn't there yet on the
// same render where it gets selected).
useEffect(() => {
const frontNode = selectedId && elementsFront.some((e) => e.id === selectedId) ? nodeRefs.current.get(selectedId) : null;
const backNode = selectedId && elementsBack.some((e) => e.id === selectedId) ? nodeRefs.current.get(selectedId) : null;
if (transformerFrontRef.current) {
transformerFrontRef.current.nodes(frontNode ? [frontNode] : []);
transformerFrontRef.current.getLayer()?.draw();
}
if (transformerBackRef.current) {
transformerBackRef.current.nodes(backNode ? [backNode] : []);
transformerBackRef.current.getLayer()?.draw();
}
}, [selectedId, elementsFront.length, elementsBack.length, nodeReadyTick]);
// Canvas text can render with a fallback font if drawn before a web font finishes
// loading — redraw once fonts are ready so custom font choices show up correctly.
useEffect(() => {
document.fonts?.ready.then(() => {
stageFrontRef.current?.getLayers().forEach((layer) => layer.batchDraw());
stageBackRef.current?.getLayers().forEach((layer) => layer.batchDraw());
});
}, []);
const updateElement = useCallback((id: string, attrs: Partial<DesignElement>) => {
setElementsFront((prev) =>
prev.some((el) => el.id === id) ? prev.map((el) => (el.id === id ? ({ ...el, ...attrs } as DesignElement) : el)) : prev,
);
setElementsBack((prev) =>
prev.some((el) => el.id === id) ? prev.map((el) => (el.id === id ? ({ ...el, ...attrs } as DesignElement) : el)) : prev,
);
}, []);
const addText = () => {
const id = uuid();
setActiveElements((prev) => [
...prev,
{
id,
type: 'text',
x: activeStageW / 2 - 40,
y: activeStageH / 2 - 10,
rotation: 0,
text: 'Your text',
fontFamily: 'Fraunces',
fontSize: 28,
fill: '#17181C',
},
]);
setSelectedId(id);
};
const handleUploadClick = () => fileInputRef.current?.click();
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const src = reader.result as string;
const img = new window.Image();
img.onload = () => {
const maxDim = Math.min(activeStageW, activeStageH) * 0.7;
const scale = Math.min(maxDim / img.width, maxDim / img.height, 1);
const id = uuid();
setActiveElements((prev) => [
...prev,
{
id,
type: 'image',
x: activeStageW / 2 - (img.width * scale) / 2,
y: activeStageH / 2 - (img.height * scale) / 2,
rotation: 0,
width: img.width * scale,
height: img.height * scale,
src,
},
]);
setSelectedId(id);
};
img.src = src;
};
reader.readAsDataURL(file);
e.target.value = '';
};
const deleteSelected = () => {
if (!selectedId) return;
setElementsFront((prev) => prev.filter((el) => el.id !== selectedId));
setElementsBack((prev) => prev.filter((el) => el.id !== selectedId));
setSelectedId(null);
};
const selected = elementsFront.find((el) => el.id === selectedId) ?? elementsBack.find((el) => el.id === selectedId);
// Calculate the effective price for made-to-order items (use personalisedPrice if available)
const effectivePriceForPersonalised = canPersonalize
? getEffectivePrice(
{
id: product.id,
basePrice: product.basePrice,
personalisedPrice: product.personalisedPrice,
salePrice: product.onSale ? product.effectivePrice : null,
saleStartsAt: null,
saleEndsAt: null,
},
[],
new Date(),
true,
).price
: product.effectivePrice;
const handleAddToBag = () => {
if (!canPersonalize) {
const flatPhoto = product.colorPhotos[color] ?? product.imageUrl ?? '';
addItem({
cartItemId: uuid(),
productId: product.id,
slug: product.slug,
name: product.name,
mockup: product.mockup,
color,
size,
quantity,
unitPrice: product.effectivePrice,
designJson: { front: [], back: [] },
designPreviewUrl: flatPhoto,
designPreviewUrlBack: null,
placementPreviewUrl: flatPhoto || null,
placementPreviewUrlBack: product.imageUrlBack ?? null,
});
setJustAdded(true);
setTimeout(() => setJustAdded(false), 2200);
return;
}
setSelectedId(null); // clear transformer handles before export
requestAnimationFrame(async () => {
const frontStage = stageFrontRef.current;
if (!frontStage) return;
// Hide the dashed guide box + tint before exporting, so the saved PNG is
// just the design itself — transparent background, no shirt, no guide
// marks — ready to hand off for printing.
const frontGuides = frontStage.find('.printGuide');
frontGuides.forEach((g) => g.hide());
const frontDataUrl = frontStage.toDataURL({ pixelRatio: 2 });
const frontBox = {
left: frontPrintArea.xPct * DISPLAY,
top: frontPrintArea.yPct * DISPLAY,
w: frontPrintArea.wPct * DISPLAY,
h: frontPrintArea.hPct * DISPLAY,
};
const frontPhotoInfo = getPhotoInfo('front');
const frontPlacementUrl = frontPhotoInfo
? await composePlacementImage(
frontPhotoInfo.url,
frontPhotoInfo.tint ? { tint: true, color } : null,
frontStage,
frontBox,
).catch(() => null)
: null;
frontGuides.forEach((g) => g.show());
let backDataUrl: string | null = null;
let backPlacementUrl: string | null = null;
if (hasBack && stageBackRef.current) {
const backStage = stageBackRef.current;
const backGuides = backStage.find('.printGuide');
backGuides.forEach((g) => g.hide());
backDataUrl = backStage.toDataURL({ pixelRatio: 2 });
const backBox = {
left: backPrintArea.xPct * DISPLAY,
top: backPrintArea.yPct * DISPLAY,
w: backPrintArea.wPct * DISPLAY,
h: backPrintArea.hPct * DISPLAY,
};
const backPhotoInfo = getPhotoInfo('back');
backPlacementUrl = backPhotoInfo
? await composePlacementImage(
backPhotoInfo.url,
backPhotoInfo.tint ? { tint: true, color } : null,
backStage,
backBox,
).catch(() => null)
: null;
backGuides.forEach((g) => g.show());
}
addItem({
cartItemId: uuid(),
productId: product.id,
slug: product.slug,
name: product.name,
mockup: product.mockup,
color,
size,
quantity,
unitPrice: effectivePriceForPersonalised,
designJson: { front: elementsFront, back: elementsBack },
designPreviewUrl: frontDataUrl,
designPreviewUrlBack: backDataUrl,
placementPreviewUrl: frontPlacementUrl ?? frontDataUrl,
placementPreviewUrlBack: backPlacementUrl ?? backDataUrl,
});
setJustAdded(true);
setTimeout(() => setJustAdded(false), 2200);
});
};
function renderPhoto(v: 'front' | 'back') {
const colorPhoto = v === 'front' ? product.colorPhotos[color] : product.colorPhotosBack[color];
if (colorPhoto) {
return <img src={colorPhoto} alt={`${product.name} in ${color}`} className="h-full w-full object-cover" />;
}
const activePhoto = v === 'front' ? product.imageUrl : product.imageUrlBack;
if (!activePhoto) {
return <ProductMockup mockup={product.mockup} color={color} />;
}
if (product.photoRecolorable) {
return (
<div className="relative h-full w-full">
<img
src={activePhoto}
alt={product.name}
className="h-full w-full object-cover"
style={{ filter: 'grayscale(1) brightness(2.1) contrast(1.15)' }}
/>
<div className="absolute inset-0" style={{ backgroundColor: color, mixBlendMode: 'multiply' }} />
</div>
);
}
return <img src={activePhoto} alt={product.name} className="h-full w-full object-cover" />;
}
// Same resolution logic as renderPhoto, but returns raw data instead of JSX —
// used when compositing the placement-reference image for the order record.
function getPhotoInfo(v: 'front' | 'back'): { url: string; tint: boolean } | null {
const colorPhoto = v === 'front' ? product.colorPhotos[color] : product.colorPhotosBack[color];
if (colorPhoto) return { url: colorPhoto, tint: false };
const activePhoto = v === 'front' ? product.imageUrl : product.imageUrlBack;
if (!activePhoto) return null; // illustration only — nothing photographic to composite onto
return { url: activePhoto, tint: product.photoRecolorable };
}
return (
<div className="grid grid-cols-1 gap-10 lg:grid-cols-[560px_1fr]">
{/* Canvas */}
<div ref={canvasContainerRef} className="w-full min-w-0 max-w-[560px]">
{hasBack && (
<div className="mb-3 inline-flex border border-line">
<button
onClick={() => setView('front')}
className={`px-4 py-1.5 text-sm ${view === 'front' ? 'bg-ink text-paper' : 'hover:text-clay'}`}
>
Front
</button>
<button
onClick={() => setView('back')}
className={`px-4 py-1.5 text-sm ${view === 'back' ? 'bg-ink text-paper' : 'hover:text-clay'}`}
>
Back
</button>
</div>
)}
{canPersonalize ? (
<>
<PrintSurface
photo={renderPhoto('front')}
printArea={frontPrintArea}
elements={elementsFront}
selectedId={selectedId}
setSelectedId={setSelectedId}
updateElement={updateElement}
nodeRefs={nodeRefs}
stageRef={stageFrontRef}
transformerRef={transformerFrontRef}
visible={view === 'front'}
caption="Dashed line marks the print area"
onNodeReady={() => setNodeReadyTick((t) => t + 1)}
scale={canvasScale}
/>
{hasBack && (
<PrintSurface
photo={renderPhoto('back')}
printArea={backPrintArea}
elements={elementsBack}
selectedId={selectedId}
setSelectedId={setSelectedId}
updateElement={updateElement}
nodeRefs={nodeRefs}
stageRef={stageBackRef}
transformerRef={transformerBackRef}
visible={view === 'back'}
caption="Dashed line marks the print area (back)"
onNodeReady={() => setNodeReadyTick((t) => t + 1)}
scale={canvasScale}
/>
)}
</>
) : (
<div className="relative aspect-square w-full max-w-[560px] border border-line bg-surface">
{renderPhoto(view)}
</div>
)}
</div>
{/* Controls */}
<div className="flex flex-col gap-8">
<div>
<h1 className="font-display text-4xl">{product.name}</h1>
<p className="mt-2 text-muted">{product.description}</p>
<p className="mt-4 flex items-baseline gap-2 font-mono text-lg">
{(() => {
const { onSale, price, originalPrice } = getEffectivePrice(
{
id: product.id,
basePrice: product.basePrice,
personalisedPrice: product.personalisedPrice,
salePrice: product.onSale ? product.effectivePrice : null,
saleStartsAt: null,
saleEndsAt: null,
},
[],
new Date(),
true,
);
return (
<>
{onSale && originalPrice != null && (
<Price cents={originalPrice} className="text-base text-muted line-through" />
)}
<Price cents={price} className={onSale ? 'text-splash-pink' : undefined} />
</>
);
})()}
</p>
</div>
<div>
<p className="tag-label mb-3">Color</p>
<div className="flex flex-wrap gap-3">
{product.colors.map((c) => (
<button
key={c}
onClick={() => setColor(c)}
aria-label={`Choose color ${c}`}
className={`h-9 w-9 rounded-full border transition-transform ${
color === c ? 'scale-110 border-ink ring-2 ring-ink ring-offset-2 ring-offset-paper' : 'border-line'
}`}
style={{ backgroundColor: c }}
/>
))}
</div>
{product.imageUrl && !product.photoRecolorable && Object.keys(product.colorPhotos).length === 0 && (
<p className="mt-2 text-xs text-muted">
This product uses a real photo, so the picture won&apos;t change color your pick is
still saved with the order.
</p>
)}
</div>
{product.sizes.length > 0 && (
<div>
<p className="tag-label mb-3">Size</p>
<div className="flex flex-wrap gap-2">
{product.sizes.map((s) => (
<button
key={s}
onClick={() => setSize(s)}
className={`min-w-[44px] border px-3 py-2 text-sm transition-colors ${
size === s
? 'border-clay bg-clay text-paper'
: 'border-line text-ink hover:border-clay hover:text-clay'
}`}
>
{s}
</button>
))}
</div>
</div>
)}
{canPersonalize && (
<div>
<p className="tag-label mb-3">
Your design {hasBack && <span className="normal-case text-muted"> editing the {view}</span>}
</p>
<div className="flex flex-wrap gap-3">
<button onClick={addText} className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper">
+ Add text
</button>
<button
onClick={handleUploadClick}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
+ Upload image
</button>
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFile} />
{selected && (
<button
onClick={deleteSelected}
className="border border-line px-4 py-2 text-sm text-muted hover:border-ink hover:text-ink"
>
Delete selected
</button>
)}
</div>
{selected?.type === 'text' && (
<div className="mt-4 flex flex-wrap items-center gap-3 border border-line bg-surface p-4">
<input
value={selected.text}
onChange={(e) => updateElement(selected.id, { text: e.target.value })}
className="border border-line bg-paper px-2 py-1 text-sm text-ink placeholder:text-muted"
placeholder="Text"
/>
<select
value={selected.fontFamily}
onChange={(e) => updateElement(selected.id, { fontFamily: e.target.value })}
className="border border-line bg-paper px-2 py-1 text-sm text-ink"
style={{ fontFamily: selected.fontFamily }}
>
{FONTS.map((f) => (
<option key={f} value={f} style={{ fontFamily: f }}>
{f}
</option>
))}
</select>
<input
type="color"
value={selected.fill}
onChange={(e) => updateElement(selected.id, { fill: e.target.value })}
className="h-8 w-8 border border-line bg-paper p-0"
/>
<label className="flex items-center gap-2 text-sm text-muted">
Size
<input
type="range"
min={10}
max={80}
value={selected.fontSize}
onChange={(e) => updateElement(selected.id, { fontSize: Number(e.target.value) })}
/>
</label>
<label className="flex items-center gap-2 text-sm text-muted">
Angle
<input
type="range"
min={0}
max={360}
value={selected.rotation || 0}
onChange={(e) => updateElement(selected.id, { rotation: Number(e.target.value) })}
/>
<span className="w-10 text-right font-mono text-xs">{selected.rotation || 0}°</span>
</label>
</div>
)}
{selected?.type === 'image' && (
<div className="mt-4 flex flex-wrap items-center gap-3 border border-line bg-surface p-4">
<label className="flex items-center gap-2 text-sm text-muted">
Angle
<input
type="range"
min={0}
max={360}
value={selected.rotation || 0}
onChange={(e) => updateElement(selected.id, { rotation: Number(e.target.value) })}
/>
<span className="w-10 text-right font-mono text-xs">{selected.rotation || 0}°</span>
</label>
</div>
)}
</div>
)}
<div className="flex items-center gap-4">
<div className="flex items-center border border-ink">
<button
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
className="px-3 py-2 text-sm"
aria-label="Decrease quantity"
>
</button>
<span className="w-10 text-center font-mono text-sm">{quantity}</span>
<button
onClick={() => setQuantity((q) => q + 1)}
className="px-3 py-2 text-sm"
aria-label="Increase quantity"
>
+
</button>
</div>
<button
onClick={handleAddToBag}
className="flex-1 bg-clay px-6 py-3 text-sm font-medium text-paper transition-colors hover:bg-clay-dark"
>
Add to bag <Price cents={product.effectivePrice * quantity} />
</button>
</div>
{justAdded && <p className="text-sm text-pine">Added to your bag.</p>}
</div>
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
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">
<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">
<div>
<div className="flex items-center gap-3">
<Image src="/logo.png" alt="Craft2Prints" width={64} height={64} className="h-14 w-14 object-contain" />
<span className="font-script text-4xl leading-none">Craft2Prints</span>
</div>
<p className="mt-3 max-w-xs text-sm text-muted">
Ink it. Print it. Love it. Every piece made to order, from your own design.
</p>
<a
href="https://www.facebook.com/Needlestocrafts"
target="_blank"
rel="noopener noreferrer"
aria-label="Craft2Prints on Facebook"
className="mt-5 inline-flex items-center gap-2 border border-line px-4 py-2.5 text-sm font-medium text-ink transition-colors hover:border-splash-blue hover:bg-splash-blue hover:text-paper"
>
<svg viewBox="0 0 24 24" fill="currentColor" className="h-6 w-6 shrink-0">
<path d="M22 12a10 10 0 1 0-11.5 9.87v-6.98H7.9V12h2.6V9.8c0-2.6 1.55-4.03 3.92-4.03 1.13 0 2.32.2 2.32.2v2.55h-1.3c-1.29 0-1.69.8-1.69 1.62V12h2.88l-.46 2.89h-2.42v6.98A10 10 0 0 0 22 12z" />
</svg>
Follow us on Facebook
</a>
</div>
<div className="text-sm text-muted">
<p className="tag-label mb-3 text-ink">Made to order</p>
<p>Printed after you check out nothing sits on a shelf first.</p>
</div>
<div className="text-sm text-muted">
<p className="tag-label mb-3 text-ink">Contact</p>
<p>sales@craft2prints.co.uk</p>
<Link href="/contact" className="mt-2 inline-block text-clay hover:text-clay-dark">
Contact us
</Link>
</div>
<div className="text-sm text-muted">
<p className="tag-label mb-3 text-ink">Stay in the loop</p>
<p>New templates and the occasional offer no spam.</p>
<NewsletterForm />
</div>
</div>
<div className="mt-10 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted">
<p>© {new Date().getFullYear()} Craft2Prints.</p>
<nav className="flex flex-wrap gap-x-4 gap-y-1">
<Link href="/privacy" className="hover:text-ink">
Privacy
</Link>
<Link href="/cookies" className="hover:text-ink">
Cookies
</Link>
<Link href="/terms" className="hover:text-ink">
Terms
</Link>
<Link href="/returns" className="hover:text-ink">
Returns
</Link>
</nav>
</div>
</div>
</footer>
);
}
+83
View File
@@ -0,0 +1,83 @@
import Link from 'next/link';
import Image from 'next/image';
import CartButton from './CartButton';
import AdminMenu from './AdminMenu';
import AccountMenu from './AccountMenu';
import ThemeToggle from './ThemeToggle';
import NavDropdown from './NavDropdown';
import CurrencySelector from './CurrencySelector';
import MobileNav from './MobileNav';
import { prisma } from '@/lib/prisma';
import { getCurrentCustomer } from '@/lib/auth';
export default async function Header() {
const [categories, collections, customer] = await Promise.all([
prisma.category.findMany({
where: { showOnPersonalised: true },
orderBy: { name: 'asc' },
}),
prisma.collection.findMany({ orderBy: { name: 'asc' } }),
getCurrentCustomer(),
]);
const personalisedLinks = [
{ href: '/made-to-order', label: 'Shop all' },
...categories.map((c) => ({ href: `/made-to-order?category=${c.slug}`, label: c.name })),
];
const shopBySections = [
{
title: 'Occasions',
links: collections
.filter((c) => c.group === 'OCCASION')
.map((c) => ({ href: `/made-to-order?collection=${c.slug}`, label: c.name })),
},
{
title: 'Gifts for',
links: collections
.filter((c) => c.group === 'RECIPIENT')
.map((c) => ({ href: `/made-to-order?collection=${c.slug}`, label: c.name })),
},
];
return (
<header className="sticky top-0 z-40 border-b border-line bg-paper/90 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-3 px-4 py-3 sm:px-6 sm:py-4">
<div className="flex items-center gap-3">
<MobileNav
personalisedLinks={personalisedLinks}
shopBySections={shopBySections}
customer={customer ? { name: customer.name, email: customer.email } : null}
/>
<Link href="/" className="flex items-center gap-2 sm:gap-3">
<Image
src="/logo.png"
alt="Craft2Prints"
width={96}
height={96}
className="h-12 w-12 object-contain sm:h-20 sm:w-20"
/>
<span className="font-script text-3xl leading-none text-ink sm:text-5xl">Craft2Prints</span>
</Link>
</div>
<nav className="hidden items-center gap-6 md:flex">
<NavDropdown label="Personalised" links={personalisedLinks} />
<Link href="/products" className="tag-label hover:text-ink">
Products
</Link>
<NavDropdown label="Shop by" sections={shopBySections} />
<Link href="/gallery" className="tag-label hover:text-ink">
Gallery
</Link>
</nav>
<div className="flex items-center gap-2 sm:gap-4">
<CurrencySelector />
<div className="hidden md:block">
<AccountMenu customer={customer ? { name: customer.name, email: customer.email } : null} />
</div>
<AdminMenu />
<ThemeToggle />
<CartButton />
</div>
</div>
</header>
);
}
+25
View File
@@ -0,0 +1,25 @@
// Shared wrapper for the static legal pages (/privacy, /cookies, /terms,
// /returns) so they stay visually identical and each page file is just content.
export default function LegalPage({
tag,
title,
lastUpdated,
children,
}: {
tag: string;
title: string;
lastUpdated: string;
children: React.ReactNode;
}) {
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<p className="tag-label text-splash-pink">{tag}</p>
<h1 className="mt-2 font-display text-3xl">{title}</h1>
<p className="mt-2 text-xs text-muted">Last updated: {lastUpdated}</p>
<div className="mt-8 space-y-6 text-sm leading-relaxed text-ink [&_h2]:font-display [&_h2]:text-xl [&_h2]:pt-2 [&_ul]:list-disc [&_ul]:space-y-1 [&_ul]:pl-5 [&_a]:text-clay hover:[&_a]:text-clay-dark [&_p]:text-muted [&_li]:text-muted">
{children}
</div>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import Link from 'next/link';
import Image from 'next/image';
import { DEFAULT_MAINTENANCE_MESSAGE } from '@/lib/settings';
export default function MaintenancePage({ message }: { message: string | null }) {
return (
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
<div className="h-1 w-24 brand-gradient" />
<Image
src="/logo.png"
alt="Craft2Prints"
width={96}
height={96}
className="mt-8 h-24 w-24 object-contain"
/>
<p className="mt-4 font-script text-3xl text-splash-pink">Back soon</p>
<h1 className="mt-2 font-display text-4xl">We&apos;re making some changes</h1>
<p className="mt-4 max-w-md text-muted">{message?.trim() || DEFAULT_MAINTENANCE_MESSAGE}</p>
<Link href="/admin/login" className="mt-12 tag-label text-muted hover:text-clay">
Admin login
</Link>
</div>
);
}
+272
View File
@@ -0,0 +1,272 @@
'use client';
import { useState } from 'react';
import { createManualSale } from '@/app/admin/accounts/actions';
import Price from './Price';
type ProductOption = {
id: string;
name: string;
basePrice: number; // pence
colors: string[];
sizes: string[];
};
type Row = {
key: number;
productId: string; // '' = free-text item
description: string;
color: string;
size: string;
quantity: number;
unitPriceInput: string; // pounds, as typed — parsed on serialize
};
function todayInput() {
return new Date().toISOString().slice(0, 10);
}
export default function ManualSaleForm({ products }: { products: ProductOption[] }) {
const [rows, setRows] = useState<Row[]>([
{ key: 1, productId: '', description: '', color: '', size: '', quantity: 1, unitPriceInput: '' },
]);
const [nextKey, setNextKey] = useState(2);
const updateRow = (key: number, patch: Partial<Row>) => {
setRows((prev) => prev.map((r) => (r.key === key ? { ...r, ...patch } : r)));
};
const pickProduct = (key: number, productId: string) => {
const product = products.find((p) => p.id === productId);
if (!product) {
// switched to free text — keep whatever was typed, clear product-specific fields
updateRow(key, { productId: '', color: '', size: '' });
return;
}
updateRow(key, {
productId,
description: product.name,
color: product.colors[0] ?? '',
size: product.sizes[0] ?? '',
unitPriceInput: (product.basePrice / 100).toFixed(2),
});
};
const addRow = () => {
setRows((prev) => [
...prev,
{ key: nextKey, productId: '', description: '', color: '', size: '', quantity: 1, unitPriceInput: '' },
]);
setNextKey((k) => k + 1);
};
const removeRow = (key: number) => {
setRows((prev) => (prev.length > 1 ? prev.filter((r) => r.key !== key) : prev));
};
const pence = (input: string) => Math.max(0, Math.round(Number(input || '0') * 100));
const total = rows.reduce((sum, r) => sum + r.quantity * pence(r.unitPriceInput), 0);
// What actually gets submitted — the server recomputes the total itself.
const itemsJson = JSON.stringify(
rows
.filter((r) => r.description.trim())
.map((r) => ({
productId: r.productId || null,
description: r.description,
color: r.productId ? r.color || null : null,
size: r.productId ? r.size || null : null,
quantity: r.quantity,
unitPrice: pence(r.unitPriceInput),
})),
);
return (
<form action={createManualSale} className="mt-10 space-y-6">
<input type="hidden" name="items" value={itemsJson} />
<div className="grid gap-4 sm:grid-cols-3">
<div>
<label htmlFor="soldAt" className="tag-label mb-2 block">
Date of sale
</label>
<input
id="soldAt"
name="soldAt"
type="date"
defaultValue={todayInput()}
required
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="buyerName" className="tag-label mb-2 block">
Buyer name (optional)
</label>
<input
id="buyerName"
name="buyerName"
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="Cash buyer at a fair? Leave blank"
/>
</div>
<div>
<label htmlFor="buyerEmail" className="tag-label mb-2 block">
Buyer email (optional)
</label>
<input
id="buyerEmail"
name="buyerEmail"
type="email"
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="Links to their account if they have one"
/>
</div>
</div>
<div>
<p className="tag-label mb-2">Items</p>
<div className="space-y-3">
{rows.map((row) => {
const product = products.find((p) => p.id === row.productId);
return (
<div key={row.key} className="border border-line bg-surface p-3">
<div className="grid gap-2 sm:grid-cols-2">
<select
value={row.productId}
onChange={(e) => pickProduct(row.key, e.target.value)}
className="border border-line bg-paper px-2 py-2 text-sm"
aria-label="Product"
>
<option value="">Free-text item</option>
{products.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<input
value={row.description}
onChange={(e) => updateRow(row.key, { description: e.target.value })}
placeholder="What was sold, e.g. Custom commission"
className="border border-line bg-paper px-3 py-2 text-sm"
aria-label="Description"
/>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
{product && product.colors.length > 0 && (
<select
value={row.color}
onChange={(e) => updateRow(row.key, { color: e.target.value })}
className="border border-line bg-paper px-2 py-2 text-sm"
aria-label="Colour"
>
{product.colors.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
)}
{product && product.sizes.length > 0 && (
<select
value={row.size}
onChange={(e) => updateRow(row.key, { size: e.target.value })}
className="border border-line bg-paper px-2 py-2 text-sm"
aria-label="Size"
>
{product.sizes.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
)}
<label className="flex items-center gap-1 text-sm text-muted">
Qty
<input
type="number"
min={1}
value={row.quantity}
onChange={(e) => updateRow(row.key, { quantity: Math.max(1, Math.floor(Number(e.target.value) || 1)) })}
className="w-16 border border-line bg-paper px-2 py-2 text-sm"
/>
</label>
<label className="flex items-center gap-1 text-sm text-muted">
£
<input
type="number"
step="0.01"
min={0}
value={row.unitPriceInput}
onChange={(e) => updateRow(row.key, { unitPriceInput: e.target.value })}
placeholder="0.00"
className="w-24 border border-line bg-paper px-2 py-2 text-sm"
aria-label="Unit price in pounds"
/>
</label>
<span className="ml-auto font-mono text-sm">
<Price cents={row.quantity * pence(row.unitPriceInput)} />
</span>
{rows.length > 1 && (
<button
type="button"
onClick={() => removeRow(row.key)}
className="tag-label text-muted hover:text-splash-pink"
>
Remove
</button>
)}
</div>
</div>
);
})}
</div>
<button
type="button"
onClick={addRow}
className="mt-3 border border-ink px-3 py-1.5 text-xs hover:border-clay hover:bg-clay hover:text-paper"
>
+ Add another item
</button>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="paymentMethod" className="tag-label mb-2 block">
Paid by
</label>
<select
id="paymentMethod"
name="paymentMethod"
className="w-full border border-line bg-paper px-3 py-2 text-sm"
>
<option value="CASH">Cash</option>
<option value="BANK_TRANSFER">Bank transfer</option>
</select>
</div>
<div>
<label htmlFor="notes" className="tag-label mb-2 block">
Notes (optional)
</label>
<input
id="notes"
name="notes"
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="e.g. Spring fair, collected in person"
/>
</div>
</div>
<div className="flex items-center justify-between border-t border-line pt-4">
<p className="font-mono text-lg">
Total <Price cents={total} />
</p>
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
Record sale
</button>
</div>
</form>
);
}
+131
View File
@@ -0,0 +1,131 @@
'use client';
import Link from 'next/link';
import { useEffect, useState } from 'react';
type NavLink = { href: string; label: string };
type NavSection = { title: string; links: NavLink[] };
export default function MobileNav({
personalisedLinks,
shopBySections,
customer,
}: {
personalisedLinks: NavLink[];
shopBySections: NavSection[];
customer: { name: string | null; email: string } | null;
}) {
const [open, setOpen] = useState(false);
const [expanded, setExpanded] = useState<string | null>(null);
// Lock background scroll while the drawer is open, and always start closed
// on route change (this component stays mounted across client-side nav).
useEffect(() => {
document.body.style.overflow = open ? 'hidden' : '';
return () => {
document.body.style.overflow = '';
};
}, [open]);
const close = () => {
setOpen(false);
setExpanded(null);
};
return (
<div className="md:hidden">
<button
onClick={() => setOpen(true)}
aria-label="Open menu"
aria-expanded={open}
className="flex h-9 w-9 items-center justify-center border border-line text-ink"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 6h18M3 12h18M3 18h18" />
</svg>
</button>
{open && (
<div className="fixed inset-0 z-50 flex">
<div className="absolute inset-0 bg-black/30" onClick={close} />
<div className="relative ml-auto flex h-full w-full max-w-xs flex-col overflow-y-auto bg-paper p-6">
<div className="flex items-center justify-between">
<span className="font-script text-3xl leading-none text-ink">Craft2Prints</span>
<button onClick={close} aria-label="Close menu" className="flex h-9 w-9 items-center justify-center border border-line text-ink">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<nav className="mt-8 flex flex-col divide-y divide-line">
<div className="py-2">
<button
onClick={() => setExpanded((s) => (s === 'personalised' ? null : 'personalised'))}
aria-expanded={expanded === 'personalised'}
className="flex w-full items-center justify-between py-2 text-left tag-label"
>
Personalised
<span className={`transition-transform ${expanded === 'personalised' ? 'rotate-180' : ''}`}></span>
</button>
{expanded === 'personalised' && (
<div className="flex flex-col pb-2 pl-2">
{personalisedLinks.map((link) => (
<Link key={link.href} href={link.href} onClick={close} className="py-2 text-sm text-muted hover:text-ink">
{link.label}
</Link>
))}
</div>
)}
</div>
<Link href="/products" onClick={close} className="py-4 tag-label">
Products
</Link>
<div className="py-2">
<button
onClick={() => setExpanded((s) => (s === 'shopby' ? null : 'shopby'))}
aria-expanded={expanded === 'shopby'}
className="flex w-full items-center justify-between py-2 text-left tag-label"
>
Shop by
<span className={`transition-transform ${expanded === 'shopby' ? 'rotate-180' : ''}`}></span>
</button>
{expanded === 'shopby' && (
<div className="flex flex-col gap-3 pb-2 pl-2">
{shopBySections
.filter((s) => s.links.length > 0)
.map((section) => (
<div key={section.title}>
<p className="text-xs font-medium uppercase tracking-wide text-muted">{section.title}</p>
<div className="flex flex-col">
{section.links.map((link) => (
<Link key={link.href} href={link.href} onClick={close} className="py-2 text-sm text-muted hover:text-ink">
{link.label}
</Link>
))}
</div>
</div>
))}
{shopBySections.every((s) => s.links.length === 0) && (
<p className="text-sm text-muted">Coming soon.</p>
)}
</div>
)}
</div>
<Link href="/gallery" onClick={close} className="py-4 tag-label">
Gallery
</Link>
<Link href={customer ? '/account' : '/account/login'} onClick={close} className="py-4 tag-label">
{customer ? (customer.name ?? 'My account') : 'Login'}
</Link>
</nav>
</div>
</div>
)}
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
'use client';
import Link from 'next/link';
import { useEffect, useRef, useState } from 'react';
type NavLink = { href: string; label: string };
type NavSection = { title: string; links: NavLink[] };
export default function NavDropdown({
label,
links,
sections,
}: {
label: string;
links?: NavLink[];
sections?: NavSection[];
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const onClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', onClickOutside);
return () => document.removeEventListener('mousedown', onClickOutside);
}, []);
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="tag-label flex items-center gap-1 hover:text-ink"
>
{label}
<span className={`inline-block transition-transform ${open ? 'rotate-180' : ''}`}></span>
</button>
{open && (
<div className="absolute left-0 top-full mt-2 w-52 border border-line bg-paper shadow-sm">
{sections ? (
sections.every((s) => s.links.length === 0) ? (
<p className="px-4 py-3 text-sm text-muted">Coming soon.</p>
) : (
<div className="divide-y divide-line">
{sections
.filter((s) => s.links.length > 0)
.map((section) => (
<div key={section.title} className="py-1">
<p className="px-4 pt-2 text-xs font-medium uppercase tracking-wide text-muted">{section.title}</p>
{section.links.map((link) => (
<Link
key={link.href + link.label}
href={link.href}
onClick={() => setOpen(false)}
className="block px-4 py-2.5 text-sm hover:bg-surface"
>
{link.label}
</Link>
))}
</div>
))}
</div>
)
) : links && links.length > 0 ? (
links.map((link) => (
<Link
key={link.href + link.label}
href={link.href}
onClick={() => setOpen(false)}
className="block px-4 py-3 text-sm hover:bg-surface"
>
{link.label}
</Link>
))
) : (
<p className="px-4 py-3 text-sm text-muted">Coming soon.</p>
)}
</div>
)}
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
'use client';
import { useState, useTransition } from 'react';
import { subscribeToNewsletter } from '@/app/actions';
import Turnstile from './Turnstile';
export default function NewsletterForm() {
const [email, setEmail] = useState('');
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);
const [isPending, startTransition] = useTransition();
return (
<div className="mt-3 max-w-xs">
<form
onSubmit={(e) => {
e.preventDefault();
const token = String(new FormData(e.currentTarget).get('cf-turnstile-response') ?? '');
startTransition(async () => {
const res = await subscribeToNewsletter(email, token);
setResult(res);
if (res.ok) setEmail('');
});
}}
className="flex flex-col gap-2"
>
<div className="flex gap-2">
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
aria-label="Email address"
className="w-full min-w-0 border border-line bg-paper px-3 py-2 text-sm"
/>
<button
type="submit"
disabled={isPending}
className="shrink-0 bg-clay px-4 py-2 text-sm font-medium text-paper hover:bg-clay-dark disabled:opacity-60"
>
{isPending ? '…' : 'Sign up'}
</button>
</div>
<Turnstile />
</form>
{result && (
<p role="status" className={`mt-2 text-xs ${result.ok ? 'text-splash-blue' : 'text-splash-pink'}`}>
{result.message}
</p>
)}
</div>
);
}
+149
View File
@@ -0,0 +1,149 @@
'use client';
import { useRef, useState } from 'react';
type Box = { xPct: number; yPct: number; wPct: number; hPct: number }; // 0-100
const DEFAULT_BOX: Box = { xPct: 30, yPct: 25, wPct: 40, hPct: 35 };
const MIN_SIZE = 6; // percent
export default function PhotoPrintAreaField({ variant = 'front' }: { variant?: 'front' | 'back' }) {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [box, setBox] = useState<Box>(DEFAULT_BOX);
const containerRef = useRef<HTMLDivElement>(null);
const dragState = useRef<{
mode: 'move' | 'resize';
startX: number;
startY: number;
startBox: Box;
} | null>(null);
const fieldName = variant === 'front' ? 'image' : 'imageBack';
const suffix = variant === 'front' ? '' : 'Back';
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) {
setPreviewUrl(null);
return;
}
setPreviewUrl(URL.createObjectURL(file));
setBox(DEFAULT_BOX);
};
const onBoxMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
dragState.current = { mode: 'move', startX: e.clientX, startY: e.clientY, startBox: box };
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
};
const onResizeMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
dragState.current = { mode: 'resize', startX: e.clientX, startY: e.clientY, startBox: box };
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
};
const onMouseMove = (e: MouseEvent) => {
const state = dragState.current;
const rect = containerRef.current?.getBoundingClientRect();
if (!state || !rect) return;
const dxPct = ((e.clientX - state.startX) / rect.width) * 100;
const dyPct = ((e.clientY - state.startY) / rect.height) * 100;
if (state.mode === 'move') {
const xPct = clamp(state.startBox.xPct + dxPct, 0, 100 - state.startBox.wPct);
const yPct = clamp(state.startBox.yPct + dyPct, 0, 100 - state.startBox.hPct);
setBox({ ...state.startBox, xPct, yPct });
} else {
const wPct = clamp(state.startBox.wPct + dxPct, MIN_SIZE, 100 - state.startBox.xPct);
const hPct = clamp(state.startBox.hPct + dyPct, MIN_SIZE, 100 - state.startBox.yPct);
setBox({ ...state.startBox, wPct, hPct });
}
};
const onMouseUp = () => {
dragState.current = null;
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
return (
<div>
<input
id={fieldName}
name={fieldName}
type="file"
accept="image/*"
onChange={handleFileChange}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
{variant === 'front' ? (
<p className="mt-1 text-xs text-muted">
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).
</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.
</p>
)}
{previewUrl && (
<div className="mt-4">
<p className="tag-label mb-2">Drag the box to mark the print area</p>
<p className="mb-2 text-xs text-muted">
Cropped to a square here that&apos;s the same crop the design tool uses, so the box
lines up correctly.
</p>
<div
ref={containerRef}
className="relative aspect-square w-full max-w-sm select-none border border-line"
style={{ touchAction: 'none' }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewUrl}
alt="Product preview"
className="absolute inset-0 h-full w-full object-cover"
draggable={false}
/>
<div
onMouseDown={onBoxMouseDown}
className="absolute cursor-move border-2 border-dashed bg-splash-pink/10"
style={{
left: `${box.xPct}%`,
top: `${box.yPct}%`,
width: `${box.wPct}%`,
height: `${box.hPct}%`,
borderColor: '#E5227E',
}}
>
<div
onMouseDown={onResizeMouseDown}
className="absolute -bottom-1.5 -right-1.5 h-4 w-4 cursor-se-resize rounded-full border border-paper bg-splash-pink"
/>
</div>
</div>
<p className="mt-1 text-xs text-muted">Drag the pink handle to resize.</p>
</div>
)}
<input type="hidden" name={`hasCustomPrintArea${suffix}`} value={previewUrl ? '1' : ''} />
<input type="hidden" name={`printX${suffix}`} value={box.xPct / 100} />
<input type="hidden" name={`printY${suffix}`} value={box.yPct / 100} />
<input type="hidden" name={`printW${suffix}`} value={box.wPct / 100} />
<input type="hidden" name={`printH${suffix}`} value={box.hPct / 100} />
</div>
);
}
function clamp(n: number, min: number, max: number) {
return Math.min(Math.max(n, min), Math.max(min, max));
}
+9
View File
@@ -0,0 +1,9 @@
'use client';
import { useCurrency } from '@/lib/CurrencyContext';
import { formatPrice } from '@/lib/currency';
export default function Price({ cents, className }: { cents: number; className?: string }) {
const { currency } = useCurrency();
return <span className={className}>{formatPrice(cents, currency)}</span>;
}
+56
View File
@@ -0,0 +1,56 @@
import Link from 'next/link';
import ProductMockup from './ProductMockup';
import Price from './Price';
import type { ProductDTO } from '@/lib/types';
const NEW_WINDOW_MS = 1000 * 60 * 60 * 24 * 30; // 30 days
export default function ProductCard({
product,
hrefBase = '/made-to-order',
}: {
product: ProductDTO;
hrefBase?: string;
}) {
const thumbnail = product.colorPhotos[product.colors[0]] ?? product.imageUrl;
const isNew = Date.now() - new Date(product.createdAt).getTime() < NEW_WINDOW_MS;
return (
<Link href={`${hrefBase}/${product.slug}`} className="group block">
<div className="relative border border-line bg-surface p-6 shadow-sm transition-all duration-300 group-hover:-translate-y-1 group-hover:border-ink group-hover:shadow-lg">
{isNew && (
<span className="tag-label absolute left-3 top-3 rounded-full bg-splash-blue/10 px-2.5 py-1 text-splash-blue">
New
</span>
)}
{product.onSale && (
<span className="tag-label absolute right-3 top-3 rounded-full bg-splash-pink/10 px-2.5 py-1 text-splash-pink">
Sale
</span>
)}
<div className="mx-auto aspect-square w-full max-w-[280px] overflow-hidden">
{thumbnail ? (
<img
src={thumbnail}
alt={product.name}
className="h-full w-full object-contain transition-transform duration-300 group-hover:scale-105"
/>
) : (
<div className="transition-transform duration-300 group-hover:scale-105">
<ProductMockup mockup={product.mockup} color={product.colors[0]} />
</div>
)}
</div>
</div>
<div className="mt-3 flex justify-end gap-2 text-right">
{product.onSale && product.originalPrice != null && (
<Price cents={product.originalPrice} className="font-mono text-sm text-muted line-through" />
)}
<Price
cents={product.effectivePrice}
className={`font-mono text-sm ${product.onSale ? 'text-splash-pink' : 'text-muted'}`}
/>
</div>
</Link>
);
}
+276
View File
@@ -0,0 +1,276 @@
'use client';
import { useId } from 'react';
type Props = {
mockup: string;
color: string;
};
const STROKE = '#17181C';
/**
* Shading is done as color-independent overlays (white/black gradients with
* blend modes) on top of the flat garment fill, so any hex color still reads
* as "lit from one side" without us having to compute per-color shades.
*/
function Shading({ clipId, variant }: { clipId: string; variant: 'garment' | 'round' }) {
const hi = `${clipId}-hi`;
const sh = `${clipId}-sh`;
return (
<>
<defs>
<linearGradient id={hi} x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#FFFFFF" stopOpacity={variant === 'round' ? 0.55 : 0.35} />
<stop offset="45%" stopColor="#FFFFFF" stopOpacity="0" />
</linearGradient>
<linearGradient id={sh} x1="100%" y1="100%" x2="0%" y2="0%">
<stop offset="0%" stopColor="#000000" stopOpacity={variant === 'round' ? 0.28 : 0.16} />
<stop offset="45%" stopColor="#000000" stopOpacity="0" />
</linearGradient>
</defs>
<g clipPath={`url(#${clipId})`}>
<rect x="0" y="0" width="500" height="500" fill={`url(#${hi})`} style={{ mixBlendMode: 'screen' }} />
<rect x="0" y="0" width="500" height="500" fill={`url(#${sh})`} style={{ mixBlendMode: 'multiply' }} />
</g>
</>
);
}
function TShirt({ color }: { color: string }) {
const id = useId();
const clipId = `tee-${id}`;
const body =
'M170 92 L112 128 L68 192 L118 232 L150 204 L150 424 C150 434 158 442 168 442 L332 442 C342 442 350 434 350 424 L350 204 L382 232 L432 192 L388 128 L330 92 C330 92 316 122 250 122 C184 122 170 92 170 92 Z';
return (
<svg viewBox="0 0 500 500" className="h-full w-full">
<defs>
<clipPath id={clipId}>
<path d={body} />
</clipPath>
</defs>
<path d={body} fill={color} stroke={STROKE} strokeWidth="2.5" strokeLinejoin="round" />
{/* fold shadows */}
<g clipPath={`url(#${clipId})`} opacity="0.5">
<path d="M195 210 C 200 300, 195 380, 205 435" fill="none" stroke="#000" strokeOpacity="0.08" strokeWidth="10" />
<path d="M310 210 C 305 300, 312 380, 300 435" fill="none" stroke="#000" strokeOpacity="0.08" strokeWidth="10" />
<path d="M150 220 L150 340" stroke="#000" strokeOpacity="0.12" strokeWidth="4" />
<path d="M350 220 L350 340" stroke="#000" strokeOpacity="0.12" strokeWidth="4" />
</g>
<Shading clipId={clipId} variant="garment" />
{/* ribbed crew neck */}
<path
d="M330 92 C 330 92 316 122 250 122 C 184 122 170 92 170 92"
fill="none"
stroke={STROKE}
strokeWidth="2.5"
/>
<path
d="M320 98 C 300 118 300 128 250 128 C 200 128 200 118 180 98"
fill="none"
stroke={STROKE}
strokeOpacity="0.45"
strokeWidth="1.5"
/>
{/* sleeve hem topstitch */}
<path d="M112 128 L68 192 L118 232" fill="none" stroke={STROKE} strokeOpacity="0.4" strokeWidth="1.2" strokeDasharray="3 3" />
<path d="M330 92 L388 128 L432 192 L382 232" fill="none" stroke={STROKE} strokeOpacity="0.4" strokeWidth="1.2" strokeDasharray="3 3" />
</svg>
);
}
function Hoodie({ color }: { color: string }) {
const id = useId();
const clipId = `hoodie-${id}`;
const body =
'M178 100 L118 138 L78 198 L128 238 L158 212 L158 250 C 132 262 120 288 120 322 L120 432 C120 440 126 446 134 446 L366 446 C374 446 380 440 380 432 L380 322 C380 288 368 262 342 250 L342 212 L372 238 L422 198 L382 138 L322 100 C322 100 304 124 250 124 C196 124 178 100 178 100 Z';
return (
<svg viewBox="0 0 500 500" className="h-full w-full">
<defs>
<clipPath id={clipId}>
<path d={body} />
</clipPath>
</defs>
<path d={body} fill={color} stroke={STROKE} strokeWidth="2.5" strokeLinejoin="round" />
<g clipPath={`url(#${clipId})`} opacity="0.5">
<path d="M200 260 C 205 330, 200 390, 208 440" fill="none" stroke="#000" strokeOpacity="0.08" strokeWidth="10" />
<path d="M300 260 C 295 330, 300 390, 292 440" fill="none" stroke="#000" strokeOpacity="0.08" strokeWidth="10" />
</g>
<Shading clipId={clipId} variant="garment" />
{/* hood */}
<path
d="M195 252 C 195 288 216 304 250 304 C 284 304 305 288 305 252"
fill="none"
stroke={STROKE}
strokeWidth="2.5"
/>
<path
d="M205 250 C 205 278 222 292 250 292 C 278 292 295 278 295 250"
fill="none"
stroke={STROKE}
strokeOpacity="0.4"
strokeWidth="1.3"
/>
{/* drawstrings */}
<circle cx="228" cy="262" r="3.2" fill="none" stroke={STROKE} strokeWidth="1.3" />
<circle cx="272" cy="262" r="3.2" fill="none" stroke={STROKE} strokeWidth="1.3" />
<path d="M228 265 C 226 292 232 305 226 320" fill="none" stroke={STROKE} strokeWidth="1.3" />
<path d="M272 265 C 274 292 268 305 274 320" fill="none" stroke={STROKE} strokeWidth="1.3" />
{/* kangaroo pocket */}
<path
d="M172 345 C 172 380 200 395 250 395 C 300 395 328 380 328 345"
fill="none"
stroke={STROKE}
strokeWidth="2"
/>
<path d="M250 345 L250 365" fill="none" stroke={STROKE} strokeOpacity="0.35" strokeWidth="1.3" />
{/* cuffs + hem ribbing */}
<path d="M158 340 L158 400 M342 340 L342 400" stroke={STROKE} strokeWidth="1.6" fill="none" />
<path d="M124 428 L376 428" stroke={STROKE} strokeOpacity="0.35" strokeWidth="1.3" strokeDasharray="2 4" />
</svg>
);
}
function Mug({ color }: { color: string }) {
const id = useId();
const clipId = `mug-${id}`;
const body = 'M142 158 L358 158 L347 402 C347 418 332 428 316 428 L184 428 C168 428 153 418 153 402 Z';
return (
<svg viewBox="0 0 500 500" className="h-full w-full">
<defs>
<clipPath id={clipId}>
<path d={body} />
</clipPath>
<radialGradient id={`${clipId}-rim`} cx="50%" cy="50%" r="50%">
<stop offset="60%" stopColor="#000" stopOpacity="0.28" />
<stop offset="100%" stopColor="#000" stopOpacity="0" />
</radialGradient>
<linearGradient id={`${clipId}-handle`} x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#FFF" stopOpacity="0.5" />
<stop offset="100%" stopColor="#000" stopOpacity="0.15" />
</linearGradient>
</defs>
<path d={body} fill={color} stroke={STROKE} strokeWidth="2.5" strokeLinejoin="round" />
<Shading clipId={clipId} variant="round" />
{/* handle — double curve for thickness + inner shading */}
<path
d="M352 196 C 415 196 440 228 440 262 C 440 296 415 328 352 328"
fill="none"
stroke={STROKE}
strokeWidth="14"
strokeLinecap="round"
/>
<path
d="M352 196 C 415 196 440 228 440 262 C 440 296 415 328 352 328"
fill="none"
stroke={`url(#${clipId}-handle)`}
strokeWidth="10"
strokeLinecap="round"
/>
{/* rim / opening */}
<ellipse cx="250" cy="158" rx="108" ry="16" fill={color} stroke={STROKE} strokeWidth="2.5" />
<ellipse cx="250" cy="158" rx="108" ry="16" fill={`url(#${clipId}-rim)`} />
<ellipse cx="250" cy="155" rx="92" ry="11" fill="none" stroke={STROKE} strokeOpacity="0.3" strokeWidth="1.2" />
</svg>
);
}
function Tumbler({ color }: { color: string }) {
const id = useId();
const clipId = `tumbler-${id}`;
const body = 'M188 148 L312 148 L296 424 C296 438 282 448 267 448 L233 448 C218 448 204 438 204 424 Z';
return (
<svg viewBox="0 0 500 500" className="h-full w-full">
<defs>
<clipPath id={clipId}>
<path d={body} />
</clipPath>
<linearGradient id={`${clipId}-lid`} x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#F2F2F2" />
<stop offset="45%" stopColor="#CBCBCB" />
<stop offset="100%" stopColor="#9A9A9A" />
</linearGradient>
</defs>
{/* lid */}
<path
d="M198 100 L302 100 L308 140 L192 140 Z"
fill={`url(#${clipId}-lid)`}
stroke={STROKE}
strokeWidth="2.2"
strokeLinejoin="round"
/>
<rect x="228" y="86" width="44" height="18" rx="6" fill={`url(#${clipId}-lid)`} stroke={STROKE} strokeWidth="1.8" />
{/* body */}
<path d={body} fill={color} stroke={STROKE} strokeWidth="2.5" strokeLinejoin="round" />
<Shading clipId={clipId} variant="round" />
{/* seam ring near lid + base */}
<path d="M192 168 L308 168" stroke={STROKE} strokeOpacity="0.3" strokeWidth="1.3" />
<path d="M206 420 L294 420" stroke={STROKE} strokeOpacity="0.3" strokeWidth="1.3" />
</svg>
);
}
function PhoneCase({ color }: { color: string }) {
const id = useId();
const clipId = `case-${id}`;
return (
<svg viewBox="0 0 500 500" className="h-full w-full">
<defs>
<clipPath id={clipId}>
<rect x="150" y="70" width="200" height="400" rx="34" />
</clipPath>
<radialGradient id={`${clipId}-lens`} cx="40%" cy="35%" r="65%">
<stop offset="0%" stopColor="#5a5a5a" />
<stop offset="60%" stopColor="#1a1a1a" />
<stop offset="100%" stopColor="#000" />
</radialGradient>
</defs>
<rect x="150" y="70" width="200" height="400" rx="34" fill={color} stroke={STROKE} strokeWidth="2.5" />
<Shading clipId={clipId} variant="garment" />
{/* camera bump */}
<rect x="180" y="100" width="66" height="66" rx="16" fill="#00000022" stroke={STROKE} strokeWidth="1.6" />
<circle cx="201" cy="121" r="11" fill={`url(#${clipId}-lens)`} stroke={STROKE} strokeWidth="1.2" />
<circle cx="197" cy="117" r="2.4" fill="#fff" fillOpacity="0.6" />
<circle cx="227" cy="143" r="11" fill={`url(#${clipId}-lens)`} stroke={STROKE} strokeWidth="1.2" />
<circle cx="223" cy="139" r="2.4" fill="#fff" fillOpacity="0.6" />
<circle cx="201" cy="148" r="7" fill={`url(#${clipId}-lens)`} stroke={STROKE} strokeWidth="1" />
{/* side buttons */}
<rect x="147" y="150" width="4" height="30" rx="2" fill={STROKE} opacity="0.6" />
<rect x="147" y="195" width="4" height="46" rx="2" fill={STROKE} opacity="0.6" />
<rect x="349" y="170" width="4" height="40" rx="2" fill={STROKE} opacity="0.6" />
</svg>
);
}
export default function ProductMockup({ mockup, color }: Props) {
switch (mockup) {
case 'hoodie':
return <Hoodie color={color} />;
case 'mug':
return <Mug color={color} />;
case 'tumbler':
return <Tumbler color={color} />;
case 'phonecase':
return <PhoneCase color={color} />;
case 'tshirt':
default:
return <TShirt color={color} />;
}
}
+22
View File
@@ -0,0 +1,22 @@
import { prisma } from '@/lib/prisma';
export default async function PromotionBanner() {
const now = new Date();
const promotion = await prisma.promotion.findFirst({
where: {
AND: [
{ OR: [{ startsAt: null }, { startsAt: { lte: now } }] },
{ OR: [{ endsAt: null }, { endsAt: { gte: now } }] },
],
},
orderBy: { createdAt: 'desc' },
});
if (!promotion) return null;
return (
<div className="brand-gradient px-6 py-2 text-center text-sm font-medium text-white">
{promotion.message}
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
'use client';
import { useState } from 'react';
type Product = { id: string; name: string };
// Checking a product auto-switches scope to "Specific items" — picking an
// item to discount only makes sense if the promotion is actually scoped to
// specific items, so we don't make the admin flip the radio manually too.
export default function PromotionScopePicker({
products,
defaultScope = 'ALL',
defaultSelectedIds = [],
}: {
products: Product[];
defaultScope?: 'ALL' | 'SPECIFIC';
defaultSelectedIds?: string[];
}) {
const [scope, setScope] = useState<'ALL' | 'SPECIFIC'>(defaultScope);
const [selected, setSelected] = useState<Set<string>>(new Set(defaultSelectedIds));
const toggleProduct = (id: string, checked: boolean) => {
setSelected((prev) => {
const next = new Set(prev);
if (checked) next.add(id);
else next.delete(id);
return next;
});
if (checked) setScope('SPECIFIC');
};
return (
<fieldset>
<legend className="tag-label mb-2 block">Applies to</legend>
<div className="space-y-3">
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="scope"
value="ALL"
checked={scope === 'ALL'}
onChange={() => setScope('ALL')}
className="h-4 w-4 accent-clay"
/>
All items
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="scope"
value="SPECIFIC"
checked={scope === 'SPECIFIC'}
onChange={() => setScope('SPECIFIC')}
className="h-4 w-4 accent-clay"
/>
Specific items
</label>
</div>
<div className="mt-3 max-h-64 space-y-2 overflow-y-auto border border-line bg-paper p-3">
{products.map((p) => (
<label key={p.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="productIds"
value={p.id}
checked={selected.has(p.id)}
onChange={(e) => toggleProduct(p.id, e.target.checked)}
className="h-4 w-4 accent-clay"
/>
{p.name}
</label>
))}
</div>
<p className="mt-1 text-xs text-muted">Only used when &quot;Specific items&quot; is selected above.</p>
</fieldset>
);
}
+260
View File
@@ -0,0 +1,260 @@
'use client';
import { useState } from 'react';
import { createPurchase } from '@/app/admin/accounts/purchases/actions';
import Price from './Price';
type ProductOption = {
id: string;
name: string;
colors: string[];
sizes: string[];
};
type Row = {
key: number;
productId: string; // '' = material / other line
description: string;
color: string;
size: string;
quantity: number;
unitCostInput: string; // pounds as typed
};
function todayInput() {
return new Date().toISOString().slice(0, 10);
}
export default function PurchaseForm({ products }: { products: ProductOption[] }) {
const [rows, setRows] = useState<Row[]>([
{ key: 1, productId: '', description: '', color: '', size: '', quantity: 1, unitCostInput: '' },
]);
const [nextKey, setNextKey] = useState(2);
const updateRow = (key: number, patch: Partial<Row>) => {
setRows((prev) => prev.map((r) => (r.key === key ? { ...r, ...patch } : r)));
};
const pickProduct = (key: number, productId: string) => {
const product = products.find((p) => p.id === productId);
if (!product) {
updateRow(key, { productId: '', color: '', size: '' });
return;
}
updateRow(key, {
productId,
description: product.name,
color: product.colors[0] ?? '',
size: product.sizes[0] ?? '',
});
};
const addRow = () => {
setRows((prev) => [
...prev,
{ key: nextKey, productId: '', description: '', color: '', size: '', quantity: 1, unitCostInput: '' },
]);
setNextKey((k) => k + 1);
};
const removeRow = (key: number) => {
setRows((prev) => (prev.length > 1 ? prev.filter((r) => r.key !== key) : prev));
};
const pence = (input: string) => Math.max(0, Math.round(Number(input || '0') * 100));
const total = rows.reduce((sum, r) => sum + r.quantity * pence(r.unitCostInput), 0);
const itemsJson = JSON.stringify(
rows
.filter((r) => r.description.trim())
.map((r) => ({
productId: r.productId || null,
description: r.description,
color: r.productId ? r.color || null : null,
size: r.productId ? r.size || null : null,
quantity: r.quantity,
unitCost: pence(r.unitCostInput),
})),
);
return (
<form action={createPurchase} className="mt-10 space-y-6" encType="multipart/form-data">
<input type="hidden" name="items" value={itemsJson} />
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="supplierName" className="tag-label mb-2 block">
Supplier
</label>
<input
id="supplierName"
name="supplierName"
required
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="e.g. AWD Apparel"
/>
</div>
<div>
<label htmlFor="purchasedAt" className="tag-label mb-2 block">
Invoice date
</label>
<input
id="purchasedAt"
name="purchasedAt"
type="date"
defaultValue={todayInput()}
required
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
</div>
</div>
<div>
<label htmlFor="invoice" className="tag-label mb-2 block">
Invoice file (optional)
</label>
<input
id="invoice"
name="invoice"
type="file"
accept="application/pdf,image/*"
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
<p className="mt-1 text-xs text-muted">PDF or photo, up to 8 MB kept with the purchase for your records.</p>
</div>
<div>
<p className="tag-label mb-2">Items</p>
<div className="space-y-3">
{rows.map((row) => {
const product = products.find((p) => p.id === row.productId);
return (
<div key={row.key} className="border border-line bg-surface p-3">
<div className="grid gap-2 sm:grid-cols-2">
<select
value={row.productId}
onChange={(e) => pickProduct(row.key, e.target.value)}
className="border border-line bg-paper px-2 py-2 text-sm"
aria-label="Stock for product"
>
<option value="">Material / other (no stock count)</option>
{products.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<input
value={row.description}
onChange={(e) => updateRow(row.key, { description: e.target.value })}
placeholder="What you bought, e.g. Vinyl rolls"
className="border border-line bg-paper px-3 py-2 text-sm"
aria-label="Description"
/>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
{product && product.colors.length > 0 && (
<select
value={row.color}
onChange={(e) => updateRow(row.key, { color: e.target.value })}
className="border border-line bg-paper px-2 py-2 text-sm"
aria-label="Colour"
>
{product.colors.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
)}
{product && product.sizes.length > 0 && (
<select
value={row.size}
onChange={(e) => updateRow(row.key, { size: e.target.value })}
className="border border-line bg-paper px-2 py-2 text-sm"
aria-label="Size"
>
{product.sizes.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
)}
<label className="flex items-center gap-1 text-sm text-muted">
Qty
<input
type="number"
min={1}
value={row.quantity}
onChange={(e) => updateRow(row.key, { quantity: Math.max(1, Math.floor(Number(e.target.value) || 1)) })}
className="w-16 border border-line bg-paper px-2 py-2 text-sm"
/>
</label>
<label className="flex items-center gap-1 text-sm text-muted">
£ each
<input
type="number"
step="0.01"
min={0}
value={row.unitCostInput}
onChange={(e) => updateRow(row.key, { unitCostInput: e.target.value })}
placeholder="0.00"
className="w-24 border border-line bg-paper px-2 py-2 text-sm"
aria-label="Unit cost in pounds"
/>
</label>
<span className="ml-auto font-mono text-sm">
<Price cents={row.quantity * pence(row.unitCostInput)} />
</span>
{rows.length > 1 && (
<button
type="button"
onClick={() => removeRow(row.key)}
className="tag-label text-muted hover:text-splash-pink"
>
Remove
</button>
)}
</div>
</div>
);
})}
</div>
<button
type="button"
onClick={addRow}
className="mt-3 border border-ink px-3 py-1.5 text-xs hover:border-clay hover:bg-clay hover:text-paper"
>
+ Add another item
</button>
<p className="mt-2 text-xs text-muted">
Lines matched to a product add to its stock count. &quot;Material / other&quot; lines are just
recorded ink, packaging, anything without a shelf count.
</p>
</div>
<div>
<label htmlFor="notes" className="tag-label mb-2 block">
Notes (optional)
</label>
<input
id="notes"
name="notes"
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="e.g. Restock ahead of Christmas fair"
/>
</div>
<div className="flex items-center justify-between border-t border-line pt-4">
<p className="font-mono text-lg">
Total <Price cents={total} />
</p>
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
Record purchase
</button>
</div>
</form>
);
}
+47
View File
@@ -0,0 +1,47 @@
'use client';
import { useEffect, useRef, useState } from 'react';
// Fades + slides content in the first time it scrolls into view. Respects
// prefers-reduced-motion globally via the CSS transition itself (see globals.css).
export default function Reveal({
children,
className = '',
delayMs = 0,
}: {
children: React.ReactNode;
className?: string;
delayMs?: number;
}) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ threshold: 0.15 },
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div
ref={ref}
className={`transition-all duration-700 ease-out ${
visible ? 'translate-y-0 opacity-100' : 'translate-y-6 opacity-0'
} ${className}`}
style={{ transitionDelay: visible ? `${delayMs}ms` : '0ms' }}
>
{children}
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
// Placeholder reviews so the product page doesn't look empty. Swap this out for
// real review data (its own Prisma model, or a reviews API) once you're ready.
const SAMPLE_REVIEWS = [
{
name: 'Priya S.',
rating: 5,
body: 'The print came out exactly how I designed it. Fabric feels heavier than I expected in a good way.',
},
{
name: 'Marcus T.',
rating: 5,
body: "Uploaded my own logo and it lined up perfectly. Shipping was faster than the estimate too.",
},
{
name: 'Aisha K.',
rating: 4,
body: 'Great quality overall. Wish there were a couple more color options for this one.',
},
];
function Stars({ rating }: { rating: number }) {
return (
<span aria-label={`${rating} out of 5 stars`} className="text-splash-orange">
{'★'.repeat(rating)}
<span className="text-line">{'★'.repeat(5 - rating)}</span>
</span>
);
}
export default function Reviews() {
const average =
Math.round((SAMPLE_REVIEWS.reduce((s, r) => s + r.rating, 0) / SAMPLE_REVIEWS.length) * 10) / 10;
return (
<div>
<div className="flex items-baseline gap-4">
<h2 className="font-display text-3xl">Reviews</h2>
<div className="flex items-center gap-2">
<Stars rating={Math.round(average)} />
<span className="text-sm text-muted">
{average} · {SAMPLE_REVIEWS.length} reviews
</span>
</div>
</div>
<div className="mt-8 grid gap-6 sm:grid-cols-3">
{SAMPLE_REVIEWS.map((r) => (
<div key={r.name} className="border border-line bg-surface p-5">
<Stars rating={r.rating} />
<p className="mt-3 text-sm text-ink">{r.body}</p>
<p className="tag-label mt-4">{r.name}</p>
</div>
))}
</div>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
const FB_APP_ID = process.env.NEXT_PUBLIC_FACEBOOK_APP_ID;
export default function ShareButtons({
message,
link,
redirectUri,
}: {
message: string;
link: string;
redirectUri: string;
}) {
const whatsappHref = `https://wa.me/?text=${encodeURIComponent(message)}`;
const messengerHref = FB_APP_ID
? `https://www.facebook.com/dialog/send?link=${encodeURIComponent(link)}&app_id=${FB_APP_ID}&redirect_uri=${encodeURIComponent(redirectUri)}`
: null;
return (
<div className="mt-6 flex flex-col gap-2">
<a
href={whatsappHref}
target="_blank"
rel="noopener noreferrer"
className="border border-ink px-4 py-3 text-center text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
Share via WhatsApp
</a>
{messengerHref ? (
<a
href={messengerHref}
target="_blank"
rel="noopener noreferrer"
className="border border-ink px-4 py-3 text-center text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
Share via Messenger
</a>
) : (
<p className="text-center text-xs text-muted">
Messenger sharing needs a Facebook App ID see the README&apos;s &quot;Sharing via
Messenger&quot; section.
</p>
)}
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
'use client';
import { useState } from 'react';
const PRESETS = ['XS', 'S', 'M', 'L', 'XL', 'XXL'];
export default function SizePicker({ name, defaultSizes = [] }: { name: string; defaultSizes?: string[] }) {
const [sizes, setSizes] = useState<string[]>(defaultSizes);
const [custom, setCustom] = useState('');
const addSize = (s: string) => {
const trimmed = s.trim();
if (!trimmed || sizes.includes(trimmed)) return;
setSizes((prev) => [...prev, trimmed]);
};
const removeSize = (s: string) => {
setSizes((prev) => prev.filter((x) => x !== s));
};
return (
<div>
<input type="hidden" name={name} value={sizes.join(',')} />
{sizes.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2">
{sizes.map((s) => (
<span
key={s}
className="flex items-center gap-1.5 border border-ink bg-ink px-3 py-1 text-xs text-paper"
>
{s}
<button
type="button"
onClick={() => removeSize(s)}
aria-label={`Remove ${s}`}
className="hover:text-splash-pink"
>
×
</button>
</span>
))}
</div>
)}
<div className="flex flex-wrap gap-2">
{PRESETS.map((s) => (
<button
key={s}
type="button"
onClick={() => addSize(s)}
disabled={sizes.includes(s)}
className="border border-line px-3 py-1.5 text-xs hover:border-clay hover:text-clay disabled:opacity-30"
>
+ {s}
</button>
))}
</div>
<div className="mt-3 flex items-center gap-2">
<input
value={custom}
onChange={(e) => setCustom(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addSize(custom);
setCustom('');
}
}}
placeholder="Custom size (e.g. One Size, 9, 10)"
className="border border-line bg-paper px-2 py-1.5 text-xs"
/>
<button
type="button"
onClick={() => {
addSize(custom);
setCustom('');
}}
className="border border-ink px-3 py-1.5 text-xs hover:border-clay hover:bg-clay hover:text-paper"
>
+ Add
</button>
</div>
<p className="mt-1 text-xs text-muted">Leave empty for products that don&apos;t need sizing.</p>
</div>
);
}
+178
View File
@@ -0,0 +1,178 @@
'use client';
import { useState } from 'react';
import { v4 as uuid } from 'uuid';
import ProductMockup from './ProductMockup';
import Price from './Price';
import { useCart } from '@/lib/cartStore';
import type { ProductDTO } from '@/lib/types';
export default function StandardProductView({ product }: { product: ProductDTO }) {
const [color, setColor] = useState(product.colors[0]);
const [size, setSize] = useState<string | null>(product.sizes[0] ?? null);
const [view, setView] = useState<'front' | 'back'>('front');
const [quantity, setQuantity] = useState(1);
const [justAdded, setJustAdded] = useState(false);
const addItem = useCart((s) => s.addItem);
const hasBack = Boolean(product.imageUrlBack);
function renderPhoto(v: 'front' | 'back') {
const colorPhoto = v === 'front' ? product.colorPhotos[color] : product.colorPhotosBack[color];
if (colorPhoto) {
return <img src={colorPhoto} alt={`${product.name} in ${color}`} className="h-full w-full object-cover" />;
}
const activePhoto = v === 'front' ? product.imageUrl : product.imageUrlBack;
if (!activePhoto) {
return <ProductMockup mockup={product.mockup} color={color} />;
}
if (product.photoRecolorable) {
return (
<div className="relative h-full w-full">
<img
src={activePhoto}
alt={product.name}
className="h-full w-full object-cover"
style={{ filter: 'grayscale(1) brightness(2.1) contrast(1.15)' }}
/>
<div className="absolute inset-0" style={{ backgroundColor: color, mixBlendMode: 'multiply' }} />
</div>
);
}
return <img src={activePhoto} alt={product.name} className="h-full w-full object-cover" />;
}
const handleAddToBag = () => {
addItem({
cartItemId: uuid(),
productId: product.id,
slug: product.slug,
name: product.name,
mockup: product.mockup,
color,
size,
quantity,
unitPrice: product.effectivePrice,
designJson: { front: [], back: [] },
designPreviewUrl: product.colorPhotos[color] ?? product.imageUrl ?? '',
designPreviewUrlBack: product.imageUrlBack ?? null,
placementPreviewUrl: product.colorPhotos[color] ?? product.imageUrl ?? null,
placementPreviewUrlBack: product.imageUrlBack ?? null,
});
setJustAdded(true);
setTimeout(() => setJustAdded(false), 2200);
};
return (
<div className="grid grid-cols-1 gap-10 lg:grid-cols-[560px_1fr]">
{/* Photo */}
<div className="w-full min-w-0 max-w-[560px]">
{hasBack && (
<div className="mb-3 inline-flex border border-line">
<button
onClick={() => setView('front')}
className={`px-4 py-1.5 text-sm ${view === 'front' ? 'bg-ink text-paper' : 'hover:text-clay'}`}
>
Front
</button>
<button
onClick={() => setView('back')}
className={`px-4 py-1.5 text-sm ${view === 'back' ? 'bg-ink text-paper' : 'hover:text-clay'}`}
>
Back
</button>
</div>
)}
<div className="relative aspect-square w-full border border-line bg-surface">
{renderPhoto(view)}
</div>
</div>
{/* Controls */}
<div className="flex flex-col gap-8">
<div>
<h1 className="font-display text-4xl">{product.name}</h1>
<p className="mt-2 text-muted">{product.description}</p>
<p className="mt-4 flex items-baseline gap-2 font-mono text-lg">
{product.onSale && product.originalPrice != null && (
<Price cents={product.originalPrice} className="text-base text-muted line-through" />
)}
<Price cents={product.effectivePrice} className={product.onSale ? 'text-splash-pink' : undefined} />
</p>
</div>
<div>
<p className="tag-label mb-3">Color</p>
<div className="flex flex-wrap gap-3">
{product.colors.map((c) => (
<button
key={c}
onClick={() => setColor(c)}
aria-label={`Choose color ${c}`}
className={`h-9 w-9 rounded-full border transition-transform ${
color === c ? 'scale-110 border-ink ring-2 ring-ink ring-offset-2 ring-offset-paper' : 'border-line'
}`}
style={{ backgroundColor: c }}
/>
))}
</div>
{product.imageUrl && !product.photoRecolorable && Object.keys(product.colorPhotos).length === 0 && (
<p className="mt-2 text-xs text-muted">
This product uses a real photo, so the picture won&apos;t change color your pick is
still saved with the order.
</p>
)}
</div>
{product.sizes.length > 0 && (
<div>
<p className="tag-label mb-3">Size</p>
<div className="flex flex-wrap gap-2">
{product.sizes.map((s) => (
<button
key={s}
onClick={() => setSize(s)}
className={`min-w-[44px] border px-3 py-2 text-sm transition-colors ${
size === s
? 'border-clay bg-clay text-paper'
: 'border-line text-ink hover:border-clay hover:text-clay'
}`}
>
{s}
</button>
))}
</div>
</div>
)}
<div className="flex items-center gap-4">
<div className="flex items-center border border-ink">
<button
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
className="px-3 py-2 text-sm"
aria-label="Decrease quantity"
>
</button>
<span className="w-10 text-center font-mono text-sm">{quantity}</span>
<button
onClick={() => setQuantity((q) => q + 1)}
className="px-3 py-2 text-sm"
aria-label="Increase quantity"
>
+
</button>
</div>
<button
onClick={handleAddToBag}
className="flex-1 bg-clay px-6 py-3 text-sm font-medium text-paper transition-colors hover:bg-clay-dark"
>
Add to bag <Price cents={product.effectivePrice * quantity} />
</button>
</div>
{justAdded && <p className="text-sm text-pine">Added to your bag.</p>}
</div>
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
'use client';
import { useState } from 'react';
import { setStockQuantity } from '@/app/admin/accounts/purchases/actions';
type ProductOption = {
id: string;
name: string;
colors: string[];
sizes: string[];
};
// "I counted the shelf" — set the absolute quantity for any product/colour/size
// combo, including ones with no stock row yet (initial stock take).
export default function StockAdjustForm({ products }: { products: ProductOption[] }) {
const [productId, setProductId] = useState(products[0]?.id ?? '');
const product = products.find((p) => p.id === productId);
const [color, setColor] = useState(product?.colors[0] ?? '');
const [size, setSize] = useState(product?.sizes[0] ?? '');
const pick = (id: string) => {
setProductId(id);
const next = products.find((p) => p.id === id);
setColor(next?.colors[0] ?? '');
setSize(next?.sizes[0] ?? '');
};
if (products.length === 0) return null;
return (
<form action={setStockQuantity} className="mt-6 flex flex-wrap items-end gap-2 border border-line bg-surface p-4">
<input type="hidden" name="productId" value={productId} />
<input type="hidden" name="color" value={color} />
<input type="hidden" name="size" value={size} />
<div>
<label className="tag-label mb-1 block">Product</label>
<select
value={productId}
onChange={(e) => pick(e.target.value)}
className="border border-line bg-paper px-2 py-2 text-sm"
>
{products.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
{product && product.colors.length > 0 && (
<div>
<label className="tag-label mb-1 block">Colour</label>
<select
value={color}
onChange={(e) => setColor(e.target.value)}
className="border border-line bg-paper px-2 py-2 text-sm"
>
{product.colors.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
)}
{product && product.sizes.length > 0 && (
<div>
<label className="tag-label mb-1 block">Size</label>
<select
value={size}
onChange={(e) => setSize(e.target.value)}
className="border border-line bg-paper px-2 py-2 text-sm"
>
{product.sizes.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
)}
<div>
<label className="tag-label mb-1 block">Count</label>
<input
type="number"
name="quantity"
required
className="w-24 border border-line bg-paper px-2 py-2 text-sm"
placeholder="0"
/>
</div>
<button type="submit" className="bg-clay px-4 py-2 text-sm font-medium text-paper hover:bg-clay-dark">
Set count
</button>
</form>
);
}
+44
View File
@@ -0,0 +1,44 @@
'use client';
import { useEffect, useState } from 'react';
export default function ThemeToggle() {
const [dark, setDark] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
setDark(document.documentElement.classList.contains('dark'));
}, []);
const toggle = () => {
const next = !dark;
setDark(next);
document.documentElement.classList.toggle('dark', next);
localStorage.setItem('theme', next ? 'dark' : 'light');
};
// Avoid rendering the wrong icon before we know the real theme (hydration-safe).
if (!mounted) {
return <div className="h-9 w-9" aria-hidden />;
}
return (
<button
onClick={toggle}
aria-label={dark ? 'Switch to light mode' : 'Switch to dark mode'}
className="flex h-9 w-9 items-center justify-center border border-line text-ink transition-colors hover:border-clay hover:text-clay"
>
{dark ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="4.5" />
<path d="M12 2v2.2M12 19.8V22M4.2 4.2l1.6 1.6M18.2 18.2l1.6 1.6M2 12h2.2M19.8 12H22M4.2 19.8l1.6-1.6M18.2 5.8l1.6-1.6" />
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5Z" />
</svg>
)}
</button>
);
}
+76
View File
@@ -0,0 +1,76 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import Script from 'next/script';
declare global {
interface Window {
turnstile?: {
render: (container: HTMLElement, options: Record<string, unknown>) => string;
remove: (widgetId: string) => void;
};
}
}
// Renders nothing if the site key isn't set yet (before a Cloudflare account
// exists), so pages don't show a broken widget.
//
// Uses Cloudflare's *explicit* render API rather than its class-based
// auto-render: auto-render only scans the DOM once, when the script first
// loads. Since this component can appear on multiple pages (this page's form
// plus the footer newsletter form present on every page), a client-side
// route change mounts a fresh `cf-turnstile` div after that one-time scan
// already ran — auto-render would silently never pick it up, and only a hard
// refresh (which reloads and rescans) would work. Calling `turnstile.render()`
// ourselves on every mount avoids that.
//
// The widget also verifies asynchronously after it renders, so submitting
// immediately (before it finishes) sends an empty/stale token and the server
// rejects it. We disable the form's submit button until Cloudflare's
// callback confirms a token is ready, and re-disable it if the token expires.
export default function Turnstile() {
const siteKey = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY;
const containerRef = useRef<HTMLDivElement>(null);
const widgetIdRef = useRef<string | null>(null);
const [scriptReady, setScriptReady] = useState(false);
useEffect(() => {
if (typeof window !== 'undefined' && window.turnstile) setScriptReady(true);
}, []);
useEffect(() => {
if (!siteKey || !scriptReady || !containerRef.current || !window.turnstile) return;
const submitButton = containerRef.current.closest('form')?.querySelector<HTMLButtonElement>('button[type="submit"]');
if (submitButton) submitButton.disabled = true;
const widgetId = window.turnstile.render(containerRef.current, {
sitekey: siteKey,
callback: () => {
if (submitButton) submitButton.disabled = false;
},
'expired-callback': () => {
if (submitButton) submitButton.disabled = true;
},
});
widgetIdRef.current = widgetId;
return () => {
if (widgetIdRef.current && window.turnstile) window.turnstile.remove(widgetIdRef.current);
widgetIdRef.current = null;
};
}, [siteKey, scriptReady]);
if (!siteKey) return null;
return (
<>
<Script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
strategy="afterInteractive"
onReady={() => setScriptReady(true)}
/>
<div ref={containerRef} />
</>
);
}