Initial commit: Craft2Prints with Stages 1-3
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
export type CategoryDTO = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
@@ -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
@@ -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' };
|
||||
}
|
||||
}
|
||||
@@ -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 } : {},
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user