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
+34
View File
@@ -0,0 +1,34 @@
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
import type { CurrencyCode } from './currency';
type CurrencyContextValue = {
currency: CurrencyCode;
setCurrency: (c: CurrencyCode) => void;
};
const CurrencyContext = createContext<CurrencyContextValue>({
currency: 'GBP',
setCurrency: () => {},
});
export function CurrencyProvider({ children }: { children: React.ReactNode }) {
const [currency, setCurrencyState] = useState<CurrencyCode>('GBP');
useEffect(() => {
const stored = localStorage.getItem('currency') as CurrencyCode | null;
if (stored) setCurrencyState(stored);
}, []);
const setCurrency = (c: CurrencyCode) => {
setCurrencyState(c);
localStorage.setItem('currency', c);
};
return <CurrencyContext.Provider value={{ currency, setCurrency }}>{children}</CurrencyContext.Provider>;
}
export function useCurrency() {
return useContext(CurrencyContext);
}
+47
View File
@@ -0,0 +1,47 @@
import { SignJWT, jwtVerify } from 'jose';
import { cookies } from 'next/headers';
export const ADMIN_SESSION_COOKIE = 'admin_session';
const SESSION_DURATION_SECONDS = 60 * 60 * 24 * 7; // 7 days
// Entirely separate from customer auth (src/lib/auth.ts) — different cookie,
// different secret, no shared code path. Logging into one never grants access
// to the other. No database table needed: there's still just one admin
// identity, ADMIN_USER/ADMIN_PASSWORD from .env, checked in the login action.
function adminSecretKey() {
const secret = process.env.ADMIN_SESSION_SECRET;
if (!secret) throw new Error('ADMIN_SESSION_SECRET is not set — add it to .env.');
return new TextEncoder().encode(secret);
}
export async function createAdminSession() {
const token = await new SignJWT({ role: 'admin' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(`${SESSION_DURATION_SECONDS}s`)
.sign(adminSecretKey());
cookies().set(ADMIN_SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: SESSION_DURATION_SECONDS,
});
}
export function clearAdminSession() {
cookies().delete(ADMIN_SESSION_COOKIE);
}
export async function isAdminAuthenticated() {
const token = cookies().get(ADMIN_SESSION_COOKIE)?.value;
if (!token) return false;
try {
await jwtVerify(token, adminSecretKey());
return true;
} catch {
return false;
}
}
+55
View File
@@ -0,0 +1,55 @@
import bcrypt from 'bcryptjs';
import { SignJWT, jwtVerify } from 'jose';
import { cookies } from 'next/headers';
import { prisma } from '@/lib/prisma';
export const SESSION_COOKIE = 'customer_session';
const SESSION_DURATION_SECONDS = 60 * 60 * 24 * 30; // 30 days
function sessionSecretKey() {
const secret = process.env.SESSION_SECRET;
if (!secret) throw new Error('SESSION_SECRET is not set — add it to .env.');
return new TextEncoder().encode(secret);
}
export async function hashPassword(password: string) {
return bcrypt.hash(password, 10);
}
export async function verifyPassword(password: string, hash: string) {
return bcrypt.compare(password, hash);
}
export async function createSession(customerId: string) {
const token = await new SignJWT({ sub: customerId })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(`${SESSION_DURATION_SECONDS}s`)
.sign(sessionSecretKey());
cookies().set(SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: SESSION_DURATION_SECONDS,
});
}
export function clearSession() {
cookies().delete(SESSION_COOKIE);
}
export async function getCurrentCustomer() {
const token = cookies().get(SESSION_COOKIE)?.value;
if (!token) return null;
try {
const { payload } = await jwtVerify(token, sessionSecretKey());
const customerId = payload.sub;
if (!customerId) return null;
return await prisma.customer.findUnique({ where: { id: customerId } });
} catch {
return null;
}
}
+48
View File
@@ -0,0 +1,48 @@
'use client';
import { create } from 'zustand';
import { persist, createJSONStorage, type StateStorage } from 'zustand/middleware';
import { get as idbGet, set as idbSet, del as idbDel } from 'idb-keyval';
import type { CartItem } from './types';
// The cart holds multiple full-size images per item (print files + placement
// references), which blows well past localStorage's ~5-10MB quota after just a
// couple of items. IndexedDB doesn't have that ceiling, so persist there instead —
// same zustand `persist` API, just a different storage backend underneath.
const indexedDBStorage: StateStorage = {
getItem: async (name: string) => (await idbGet(name)) ?? null,
setItem: async (name: string, value: string) => idbSet(name, value),
removeItem: async (name: string) => idbDel(name),
};
type CartState = {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (cartItemId: string) => void;
setQuantity: (cartItemId: string, quantity: number) => void;
clear: () => void;
total: () => number;
};
export const useCart = create<CartState>()(
persist(
(set, get) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
removeItem: (cartItemId) =>
set((state) => ({ items: state.items.filter((i) => i.cartItemId !== cartItemId) })),
setQuantity: (cartItemId, quantity) =>
set((state) => ({
items: state.items.map((i) =>
i.cartItemId === cartItemId ? { ...i, quantity: Math.max(1, quantity) } : i,
),
})),
clear: () => set({ items: [] }),
total: () => get().items.reduce((sum, i) => sum + i.unitPrice * i.quantity, 0),
}),
{
name: 'personalize-studio-cart',
storage: createJSONStorage(() => indexedDBStorage),
},
),
);
+5
View File
@@ -0,0 +1,5 @@
export type CategoryDTO = {
id: string;
name: string;
slug: string;
};
+18
View File
@@ -0,0 +1,18 @@
export type CurrencyCode = 'GBP' | 'USD' | 'EUR';
// Prices are stored as an integer in GBP pence — that's the canonical/base currency
// for this site. These rates are fixed approximations for customer-facing display
// only; they are NOT live exchange rates and are NOT used for actual payment
// amounts (checkout charges in GBP regardless of what's displayed here). Update
// them periodically if you want display prices to stay roughly current.
export const CURRENCIES: { code: CurrencyCode; symbol: string; rate: number; label: string }[] = [
{ code: 'GBP', symbol: '£', rate: 1, label: 'GBP — British Pound' },
{ code: 'USD', symbol: '$', rate: 1.27, label: 'USD — US Dollar' },
{ code: 'EUR', symbol: '€', rate: 1.17, label: 'EUR — Euro' },
];
export function formatPrice(basePenceGBP: number, currency: CurrencyCode): string {
const info = CURRENCIES.find((c) => c.code === currency) ?? CURRENCIES[0];
const converted = (basePenceGBP / 100) * info.rate;
return `${info.symbol}${converted.toFixed(2)}`;
}
+175
View File
@@ -0,0 +1,175 @@
import nodemailer from 'nodemailer';
function getTransporter() {
return nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT ?? 587),
secure: Number(process.env.SMTP_PORT ?? 587) === 465,
auth: process.env.SMTP_USER
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD }
: undefined,
});
}
type SendProofEmailArgs = {
to: string;
customerName: string | null;
link: string;
productName: string;
note: string | null;
};
export async function sendProofEmail({ to, customerName, link, productName, note }: SendProofEmailArgs) {
if (!process.env.SMTP_HOST) {
return { sent: false, reason: 'SMTP not configured — set SMTP_HOST etc. in .env' as const };
}
const transporter = getTransporter();
const greeting = customerName ? `Hi ${customerName},` : 'Hi,';
const noteLine = note ? `\n\nA note from us: "${note}"` : '';
try {
await transporter.sendMail({
from: process.env.MAIL_FROM ?? process.env.SMTP_USER,
to,
subject: `Your ${productName} design is ready to review`,
text: `${greeting}\n\nYour ${productName} design is ready to review and approve:\n${link}${noteLine}\n\n— Craft2Prints`,
html: `<p>${greeting}</p><p>Your ${productName} design is ready to review and approve:</p><p><a href="${link}">${link}</a></p>${
note ? `<p>A note from us: <em>${note}</em></p>` : ''
}<p>— Craft2Prints</p>`,
});
return { sent: true as const };
} catch (err) {
return { sent: false as const, reason: err instanceof Error ? err.message : 'Unknown error sending email' };
}
}
type SendPasswordResetEmailArgs = {
to: string;
name: string | null;
link: string;
};
export async function sendPasswordResetEmail({ to, name, link }: SendPasswordResetEmailArgs) {
if (!process.env.SMTP_HOST) {
return { sent: false, reason: 'SMTP not configured — set SMTP_HOST etc. in .env' as const };
}
const transporter = getTransporter();
const greeting = name ? `Hi ${name},` : 'Hi,';
try {
await transporter.sendMail({
from: process.env.MAIL_FROM ?? process.env.SMTP_USER,
to,
subject: 'Reset your Craft2Prints password',
text: `${greeting}\n\nUse the link below to reset your password. It expires in 1 hour.\n${link}\n\nIf you didn't request this, you can ignore this email.\n\n— Craft2Prints`,
html: `<p>${greeting}</p><p>Use the link below to reset your password. It expires in 1 hour.</p><p><a href="${link}">${link}</a></p><p>If you didn't request this, you can ignore this email.</p><p>— Craft2Prints</p>`,
});
return { sent: true as const };
} catch (err) {
return { sent: false as const, reason: err instanceof Error ? err.message : 'Unknown error sending email' };
}
}
type SendCustomRequestNotificationArgs = {
customerName: string;
productName: string;
note: string | null;
adminLink: string;
};
export async function sendCustomRequestNotification({
customerName,
productName,
note,
adminLink,
}: SendCustomRequestNotificationArgs) {
const to = process.env.ADMIN_NOTIFY_EMAIL ?? process.env.SMTP_USER;
if (!process.env.SMTP_HOST || !to) {
return { sent: false, reason: 'SMTP not configured — set SMTP_HOST etc. in .env' as const };
}
const transporter = getTransporter();
const noteLine = note ? `\n\nTheir note: "${note}"` : '';
try {
await transporter.sendMail({
from: process.env.MAIL_FROM ?? process.env.SMTP_USER,
to,
subject: `New photo request from ${customerName}`,
text: `${customerName} sent in a photo for a ${productName}.${noteLine}\n\nReview it here:\n${adminLink}`,
html: `<p>${customerName} sent in a photo for a ${productName}.</p>${
note ? `<p>Their note: <em>${note}</em></p>` : ''
}<p><a href="${adminLink}">${adminLink}</a></p>`,
});
return { sent: true as const };
} catch (err) {
return { sent: false as const, reason: err instanceof Error ? err.message : 'Unknown error sending email' };
}
}
type SendContactMessageNotificationArgs = {
name: string;
email: string;
message: string;
};
export async function sendContactMessageNotification({ name, email, message }: SendContactMessageNotificationArgs) {
const to = process.env.ADMIN_NOTIFY_EMAIL ?? process.env.SMTP_USER;
if (!process.env.SMTP_HOST || !to) {
return { sent: false, reason: 'SMTP not configured — set SMTP_HOST etc. in .env' as const };
}
const transporter = getTransporter();
try {
await transporter.sendMail({
from: process.env.MAIL_FROM ?? process.env.SMTP_USER,
to,
replyTo: email,
subject: `New contact message from ${name}`,
text: `${name} (${email}) sent a message via the contact page:\n\n${message}`,
html: `<p>${name} (${email}) sent a message via the contact page:</p><p>${message.replace(/\n/g, '<br>')}</p>`,
});
return { sent: true as const };
} catch (err) {
return { sent: false as const, reason: err instanceof Error ? err.message : 'Unknown error sending email' };
}
}
type SendCustomerBroadcastArgs = {
to: string;
name: string | null;
subject: string;
message: string;
attachment?: { filename: string; content: Buffer; contentType?: string } | null;
};
export async function sendCustomerBroadcast({ to, name, subject, message, attachment }: SendCustomerBroadcastArgs) {
if (!process.env.SMTP_HOST) {
return { sent: false, reason: 'SMTP not configured — set SMTP_HOST etc. in .env' as const };
}
const transporter = getTransporter();
const greeting = name ? `Hi ${name},` : 'Hi,';
const htmlMessage = message.replace(/\n/g, '<br>');
try {
await transporter.sendMail({
from: process.env.MAIL_FROM ?? process.env.SMTP_USER,
to,
subject,
text: `${greeting}\n\n${message}\n\n— Craft2Prints\n\nYou're receiving this because you're on our Craft2Prints mailing list.`,
html: `<p>${greeting}</p><p>${htmlMessage}</p><p>— Craft2Prints</p><p style="font-size:12px;color:#888">You're receiving this because you're on our Craft2Prints mailing list.</p>`,
attachments: attachment ? [attachment] : undefined,
});
return { sent: true as const };
} catch (err) {
return { sent: false as const, reason: err instanceof Error ? err.message : 'Unknown error sending email' };
}
}
+25
View File
@@ -0,0 +1,25 @@
import { prisma } from '@/lib/prisma';
type MailingListSource = 'CUSTOMER' | 'NEWSLETTER' | 'GUEST';
// Never touches `subscribed` or `source` on an existing row — so re-subscribing
// to the newsletter, or placing a second guest order, can't silently undo an
// admin's opt-out or overwrite how someone was first attributed.
export async function upsertMailingListEntry({
email,
name,
source,
}: {
email: string;
name?: string | null;
source: MailingListSource;
}) {
const trimmed = email.trim().toLowerCase();
if (!trimmed) return;
await prisma.mailingListEntry.upsert({
where: { email: trimmed },
create: { email: trimmed, name: name || null, source },
update: name ? { name } : {},
});
}
+20
View File
@@ -0,0 +1,20 @@
import type { PrintArea } from './types';
export const MOCKUP_OPTIONS = [
{ value: 'tshirt', label: 'T-shirt' },
{ value: 'hoodie', label: 'Hoodie' },
{ value: 'mug', label: 'Mug' },
{ value: 'tumbler', label: 'Tumbler' },
{ value: 'phonecase', label: 'Phone case' },
] as const;
// Print area is tuned per illustration, so new products reuse the same
// preset as whichever template they're based on rather than asking the
// admin to guess coordinates.
export const DEFAULT_PRINT_AREAS: Record<string, PrintArea> = {
tshirt: { xPct: 0.32, yPct: 0.27, wPct: 0.36, hPct: 0.34 },
hoodie: { xPct: 0.33, yPct: 0.34, wPct: 0.34, hPct: 0.28 },
mug: { xPct: 0.22, yPct: 0.32, wPct: 0.56, hPct: 0.28 },
tumbler: { xPct: 0.26, yPct: 0.28, wPct: 0.48, hPct: 0.36 },
phonecase: { xPct: 0.28, yPct: 0.16, wPct: 0.44, hPct: 0.5 },
};
+72
View File
@@ -0,0 +1,72 @@
type SalePricingFields = {
id: string;
basePrice: number;
personalisedPrice: number | null;
salePrice: number | null;
saleStartsAt: Date | null;
saleEndsAt: Date | null;
};
export type ActivePromotion = {
discountPercent: number;
scope: 'ALL' | 'SPECIFIC';
productIds: Set<string>;
};
type PromotionForActiveCheck = {
discountPercent: number;
scope: string;
startsAt: Date | null;
endsAt: Date | null;
products: { id: string }[];
};
// Filters raw Promotion rows down to ones whose date window is active right
// now. Call once per request (src/lib/promotions.ts) and pass the result into
// every getEffectivePrice call — never re-derive this per product.
export function getActivePromotions(promotions: PromotionForActiveCheck[], now = new Date()): ActivePromotion[] {
return promotions
.filter((p) => (p.startsAt == null || p.startsAt <= now) && (p.endsAt == null || p.endsAt >= now))
.map((p) => ({
discountPercent: p.discountPercent,
scope: p.scope as 'ALL' | 'SPECIFIC',
productIds: new Set(p.products.map((x) => x.id)),
}));
}
// Single source of truth for what a product actually costs right now — used
// both for display (src/lib/product.ts) and for the actual amount charged at
// checkout (src/app/api/checkout/route.ts). Never duplicate this logic
// elsewhere. A product's own manual sale price and any active promotions that
// cover it (scope "ALL", or "SPECIFIC" naming this product) all compete —
// whichever produces the lowest price wins, so discounts never stack.
export function getEffectivePrice(
product: SalePricingFields,
activePromotions: ActivePromotion[] = [],
now = new Date(),
usePersonalisedPrice = false,
) {
const basePrice = usePersonalisedPrice && product.personalisedPrice != null ? product.personalisedPrice : product.basePrice;
const candidates = [basePrice];
const manualSaleActive =
product.salePrice != null &&
(product.saleStartsAt == null || product.saleStartsAt <= now) &&
(product.saleEndsAt == null || product.saleEndsAt >= now);
if (manualSaleActive) candidates.push(product.salePrice!);
for (const promo of activePromotions) {
if (promo.scope === 'ALL' || promo.productIds.has(product.id)) {
candidates.push(Math.round(basePrice * (1 - promo.discountPercent / 100)));
}
}
const price = Math.min(...candidates);
const onSale = price < basePrice;
return {
onSale,
price,
originalPrice: onSale ? basePrice : null,
};
}
+7
View File
@@ -0,0 +1,7 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
+50
View File
@@ -0,0 +1,50 @@
import type { Product as PrismaProduct, DesignProof as PrismaDesignProof } from '@prisma/client';
import type { ProductDTO, ProofDTO } from './types';
import { getEffectivePrice, type ActivePromotion } from './pricing';
export function toProductDTO(p: PrismaProduct, activePromotions: ActivePromotion[] = []): ProductDTO {
const { onSale, price, originalPrice } = getEffectivePrice(p, activePromotions);
return {
id: p.id,
slug: p.slug,
name: p.name,
category: p.category,
description: p.description,
basePrice: p.basePrice,
personalisedPrice: p.personalisedPrice,
colors: JSON.parse(p.colors),
colorPhotos: p.colorPhotos ? JSON.parse(p.colorPhotos) : {},
colorPhotosBack: p.colorPhotosBack ? JSON.parse(p.colorPhotosBack) : {},
sizes: p.sizes ? JSON.parse(p.sizes) : [],
mockup: p.mockup,
printArea: JSON.parse(p.printArea),
printAreaBack: p.printAreaBack ? JSON.parse(p.printAreaBack) : null,
imageUrl: p.imageUrl,
imageUrlBack: p.imageUrlBack,
photoRecolorable: p.photoRecolorable,
showOnPersonalised: p.showOnPersonalised,
showOnProducts: p.showOnProducts,
createdAt: p.createdAt.toISOString(),
onSale,
effectivePrice: price,
originalPrice,
};
}
export function toProofDTO(p: PrismaDesignProof & { product: PrismaProduct }): ProofDTO {
return {
id: p.id,
productId: p.productId,
productName: p.product.name,
productSlug: p.product.slug,
customerName: p.customerName,
customerEmail: p.customerEmail,
color: p.color,
quantity: p.quantity,
unitPrice: p.unitPrice,
imageDataUrl: p.imageDataUrl,
note: p.note,
status: p.status as ProofDTO['status'],
createdAt: p.createdAt.toISOString(),
};
}
+10
View File
@@ -0,0 +1,10 @@
import { prisma } from './prisma';
import { getActivePromotions } from './pricing';
// One query per page — never call this per-product, that would N+1.
export async function fetchActivePromotions() {
const promotions = await prisma.promotion.findMany({
include: { products: { select: { id: true } } },
});
return getActivePromotions(promotions);
}
+38
View File
@@ -0,0 +1,38 @@
import { prisma } from '@/lib/prisma';
const PRUNE_AGE_MS = 24 * 60 * 60 * 1000; // 24h
// Reads the first hop of x-forwarded-for, which is what the reverse proxy in
// front of most hosts (Vercel included) sets to the real client IP. Falls
// back to a shared bucket if it's ever missing (e.g. local dev without a
// proxy) rather than throwing — worse rate limiting beats a crashed request.
export function getClientIp(headersList: Headers) {
const forwarded = headersList.get('x-forwarded-for');
if (forwarded) return forwarded.split(',')[0].trim();
return 'unknown';
}
// DB-backed rather than in-memory: an in-memory counter resets on every
// serverless cold start and doesn't share state across instances, so it
// would silently stop working outside a single always-on server.
export async function checkRateLimit(key: string, limit: number, windowMs: number) {
const windowStart = new Date(Date.now() - windowMs);
const count = await prisma.rateLimitAttempt.count({
where: { key, createdAt: { gte: windowStart } },
});
if (count >= limit) {
return { allowed: false as const };
}
await prisma.rateLimitAttempt.create({ data: { key } });
// Opportunistic cleanup so the table doesn't grow unbounded — cheap at
// this scale, no separate cron needed.
await prisma.rateLimitAttempt.deleteMany({
where: { createdAt: { lt: new Date(Date.now() - PRUNE_AGE_MS) } },
});
return { allowed: true as const };
}
+15
View File
@@ -0,0 +1,15 @@
import { prisma } from './prisma';
export const SITE_SETTINGS_ID = 'singleton';
export const DEFAULT_MAINTENANCE_MESSAGE = "We're currently updating the site — back shortly!";
// Read-only — never writes on read, so it's cheap to call from the root layout
// on every request. The row is created/updated only from the admin maintenance
// action (upsert). Returns safe defaults when the row doesn't exist yet.
export async function getSiteSettings() {
const row = await prisma.siteSetting.findUnique({ where: { id: SITE_SETTINGS_ID } });
return {
maintenanceMode: row?.maintenanceMode ?? false,
maintenanceMessage: row?.maintenanceMessage ?? null,
};
}
+37
View File
@@ -0,0 +1,37 @@
import { prisma } from './prisma';
// Single entry point for every stock movement — purchases in (positive
// delta), sales out (negative delta), deletions undoing either. Counts are
// info-only and may go negative (the stock page highlights that as "your
// records are out of sync"); nothing here ever blocks a sale.
//
// `size` is normalised to '' for sizeless products (mugs, phone cases) so the
// (productId, color, size) unique index actually dedupes — SQLite treats
// NULLs in a unique index as all distinct.
export async function adjustStock(productId: string, color: string, size: string | null, delta: number) {
if (delta === 0) return;
const sizeKey = size ?? '';
await prisma.stockLevel.upsert({
where: { productId_color_size: { productId, color, size: sizeKey } },
create: { productId, color, size: sizeKey, quantity: delta },
update: { quantity: { increment: delta } },
});
}
type StockMovableItem = {
productId: string | null;
color: string | null;
size: string | null;
quantity: number;
};
// Applies a batch of sale/purchase lines to stock. `direction` +1 for stock
// coming in (purchases), -1 for going out (sales). Lines without a productId
// (free-text sale items, material purchase lines) carry no stock and are
// skipped.
export async function applyStockMovement(items: StockMovableItem[], direction: 1 | -1) {
for (const item of items) {
if (!item.productId) continue;
await adjustStock(item.productId, item.color ?? '', item.size, direction * item.quantity);
}
}
+9
View File
@@ -0,0 +1,9 @@
import Stripe from 'stripe';
if (!process.env.STRIPE_SECRET_KEY) {
console.warn('STRIPE_SECRET_KEY is not set — checkout will fail until you add it to .env');
}
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? '', {
apiVersion: '2024-06-20',
});
+24
View File
@@ -0,0 +1,24 @@
const VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
// Skips verification (returns success) when TURNSTILE_SECRET_KEY isn't set,
// same graceful-degradation pattern as the SMTP-not-configured checks in
// src/lib/mail.ts — forms keep working before a Cloudflare account exists,
// rather than hard-blocking every submission.
export async function verifyTurnstileToken(token: string | null, ip: string) {
const secret = process.env.TURNSTILE_SECRET_KEY;
if (!secret) return { success: true as const };
if (!token) return { success: false as const };
try {
const res = await fetch(VERIFY_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ secret, response: token, remoteip: ip }),
});
const data = (await res.json()) as { success: boolean };
return { success: data.success };
} catch {
return { success: false as const };
}
}
+102
View File
@@ -0,0 +1,102 @@
export type DesignElement =
| {
id: string;
type: 'text';
x: number;
y: number;
rotation: number;
text: string;
fontFamily: string;
fontSize: number;
fill: string;
}
| {
id: string;
type: 'image';
x: number;
y: number;
rotation: number;
width: number;
height: number;
src: string; // base64 data URL
};
export type PrintArea = {
xPct: number;
yPct: number;
wPct: number;
hPct: number;
};
export type ProductDTO = {
id: string;
slug: string;
name: string;
category: string; // matches a Category.slug — categories are admin-managed, not a fixed set
description: string;
basePrice: number;
personalisedPrice: number | null; // higher price for made-to-order items; null = use basePrice
colors: string[];
colorPhotos: Record<string, string>;
colorPhotosBack: Record<string, string>;
sizes: string[];
mockup: string;
printArea: PrintArea;
printAreaBack: PrintArea | null;
imageUrl: string | null;
imageUrlBack: string | null;
photoRecolorable: boolean;
showOnPersonalised: boolean;
showOnProducts: boolean;
createdAt: string;
onSale: boolean;
effectivePrice: number; // basePrice, or the sale price while a sale is active
originalPrice: number | null; // set to basePrice when onSale, so the UI can show a strikethrough
};
export type CartItem = {
cartItemId: string;
productId: string;
slug: string;
name: string;
mockup: string;
color: string;
size: string | null;
quantity: number;
unitPrice: number;
designJson: { front: DesignElement[]; back: DesignElement[] };
designPreviewUrl: string;
designPreviewUrlBack: string | null;
placementPreviewUrl: string | null;
placementPreviewUrlBack: string | null;
};
export type ProofDTO = {
id: string;
productId: string;
productName: string;
productSlug: string;
customerName: string | null;
customerEmail: string;
color: string;
quantity: number;
unitPrice: number;
imageDataUrl: string;
note: string | null;
status: 'PENDING' | 'APPROVED';
createdAt: string;
};
export type MessageDTO = {
id: string;
proofId: string;
sender: 'ADMIN' | 'CUSTOMER';
body: string;
createdAt: string;
};
export type CustomerDTO = {
id: string;
email: string;
name: string | null;
};