Add flat-rate shipping, PayPal checkout, and order confirmation emails

- Create shipping.ts with flat-rate tiers: UK £10, Europe £25, International £40
- Integrate shipping cost into PayPal order breakdown (item_total + shipping)
- Add server-side shipping calculation in order creation API (prevents tampering)
- Update checkout page to show real-time shipping cost based on country selection
- Add subtotal/shipping/total breakdown to order confirmation page
- Add order confirmation emails via SMTP (customer + Katheryn notification)
- Include shipping breakdown in email templates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-13 12:30:36 -07:00
parent 462b34f114
commit 2b5f2cf91d
9 changed files with 924 additions and 32 deletions

View File

@ -0,0 +1,102 @@
import { NextRequest, NextResponse } from 'next/server';
import { capturePayPalOrder } from '@/lib/paypal';
import { updateOrder, updateArtworkStatus } from '@/lib/directus-admin';
import { sendOrderConfirmation } from '@/lib/email';
import { createDirectus, rest, staticToken, readItem, readItems } from '@directus/sdk';
const DIRECTUS_INTERNAL_URL = process.env.DIRECTUS_INTERNAL_URL || 'http://katheryn-cms:8055';
const STORE_TOKEN = process.env.DIRECTUS_STORE_TOKEN || '';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const adminClient = createDirectus<any>(DIRECTUS_INTERNAL_URL)
.with(staticToken(STORE_TOKEN))
.with(rest());
interface CapturePayload {
paypalOrderId: string;
orderId: number;
}
export async function POST(request: NextRequest) {
try {
const body: CapturePayload = await request.json();
const { paypalOrderId, orderId } = body;
if (!paypalOrderId || !orderId) {
return NextResponse.json({ error: 'Missing paypalOrderId or orderId' }, { status: 400 });
}
// Capture payment with PayPal
const captureResult = await capturePayPalOrder(paypalOrderId);
if (captureResult.status !== 'COMPLETED') {
await updateOrder(orderId, { status: 'payment_failed' });
return NextResponse.json(
{ error: `Payment not completed. Status: ${captureResult.status}` },
{ status: 400 },
);
}
const captureId = captureResult.purchase_units[0]?.payments?.captures[0]?.id;
const payerEmail = captureResult.payer?.email_address;
// Update order status in Directus
await updateOrder(orderId, {
status: 'paid',
paypal_capture_id: captureId,
paypal_payer_email: payerEmail,
});
// Get order items to mark artworks as sold
const orderItems = await adminClient.request(
readItems('order_items', {
filter: { order_id: { _eq: orderId } },
fields: ['artwork_id', 'artwork_name', 'price', 'currency'],
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as any[];
// Mark each artwork as sold
for (const item of orderItems) {
if (item.artwork_id) {
await updateArtworkStatus(item.artwork_id, 'sold');
}
}
// Get full order for email
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const order = await adminClient.request(readItem('orders', orderId)) as any;
// Send confirmation email (non-blocking)
sendOrderConfirmation({
orderId,
customerEmail: order.customer_email,
customerName: `${order.customer_first_name || ''} ${order.customer_last_name || ''}`.trim() || 'Customer',
items: orderItems.map(item => ({
name: item.artwork_name,
price: parseFloat(item.price),
currency: item.currency,
})),
subtotal: parseFloat(order.subtotal || order.total),
shippingCost: parseFloat(order.shipping_cost || '0'),
total: parseFloat(order.total),
currency: order.currency,
shippingAddress: order.shipping_address,
shippingCity: order.shipping_city,
shippingPostcode: order.shipping_postcode,
shippingCountry: order.shipping_country,
}).catch(err => console.error('Email send error:', err));
return NextResponse.json({
success: true,
orderId,
captureId,
});
} catch (error) {
console.error('Capture order error:', error);
return NextResponse.json(
{ error: 'Failed to capture payment' },
{ status: 500 },
);
}
}

View File

@ -0,0 +1,129 @@
import { NextRequest, NextResponse } from 'next/server';
import { createPayPalOrder, type PayPalItem } from '@/lib/paypal';
import { createOrder, createOrderItems, getArtworkPrice, updateOrder } from '@/lib/directus-admin';
import { getShippingCost } from '@/lib/shipping';
interface CartItemPayload {
artworkId: number;
artworkName: string;
price: number;
currency: string;
quantity: number;
}
interface CreateOrderPayload {
items: CartItemPayload[];
shipping: {
email: string;
firstName: string;
lastName: string;
address: string;
city: string;
postcode: string;
country: string;
phone?: string;
notes?: string;
};
}
export async function POST(request: NextRequest) {
try {
const body: CreateOrderPayload = await request.json();
const { items, shipping } = body;
if (!items?.length || !shipping?.email) {
return NextResponse.json({ error: 'Missing items or shipping info' }, { status: 400 });
}
// Verify prices against Directus to prevent tampering
for (const item of items) {
const artwork = await getArtworkPrice(item.artworkId);
if (artwork.status === 'sold') {
return NextResponse.json(
{ error: `"${item.artworkName}" is no longer available` },
{ status: 409 },
);
}
const gbp = parseFloat(artwork.price_gbp || '0');
const usd = parseFloat(artwork.price_usd || '0');
const expectedPrice = gbp > 0 ? gbp : usd;
if (Math.abs(expectedPrice - item.price) > 0.01) {
return NextResponse.json(
{ error: `Price mismatch for "${item.artworkName}"` },
{ status: 409 },
);
}
}
// Calculate total with shipping
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const currency = items[0].currency;
const shippingCost = getShippingCost(shipping.country, currency);
const total = subtotal + shippingCost;
// Create order in Directus
const order = await createOrder({
customer_email: shipping.email,
customer_first_name: shipping.firstName,
customer_last_name: shipping.lastName,
shipping_address: shipping.address,
shipping_city: shipping.city,
shipping_postcode: shipping.postcode,
shipping_country: shipping.country,
phone: shipping.phone,
notes: shipping.notes,
subtotal,
shipping_cost: shippingCost,
total,
currency,
status: 'pending',
});
// Create order items
await createOrderItems(
items.map(item => ({
order_id: order.id,
artwork_id: item.artworkId,
artwork_name: item.artworkName,
price: item.price,
currency: item.currency,
quantity: item.quantity,
})),
);
// Create PayPal order
const paypalItems: PayPalItem[] = items.map(item => ({
name: item.artworkName,
unit_amount: {
currency_code: item.currency,
value: item.price.toFixed(2),
},
quantity: String(item.quantity),
}));
const paypalOrder = await createPayPalOrder(
total,
currency,
paypalItems,
shippingCost,
String(order.id),
);
// Store PayPal order ID on our order record
await updateOrder(order.id, { paypal_order_id: paypalOrder.id });
return NextResponse.json({
orderId: order.id,
paypalOrderId: paypalOrder.id,
});
} catch (error) {
console.error('Create order error:', error);
return NextResponse.json(
{ error: 'Failed to create order' },
{ status: 500 },
);
}
}

View File

@ -3,11 +3,17 @@
import { useState } from 'react'; import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import { useRouter } from 'next/navigation';
import { useCart } from '@/context/cart-context'; import { useCart } from '@/context/cart-context';
import { getAssetUrl } from '@/lib/directus'; import { getAssetUrl } from '@/lib/directus';
import { PayPalCheckout } from '@/components/paypal-checkout';
import { getShippingCost } from '@/lib/shipping';
export default function CheckoutPage() { export default function CheckoutPage() {
const { items, subtotal, removeItem, itemCount } = useCart(); const { items, subtotal, removeItem, itemCount, clearCart } = useCart();
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [formValid, setFormValid] = useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
email: '', email: '',
firstName: '', firstName: '',
@ -32,12 +38,26 @@ export default function CheckoutPage() {
); );
} }
const handleSubmit = async (e: React.FormEvent) => { const handleFieldChange = (field: string, value: string) => {
e.preventDefault(); const updated = { ...formData, [field]: value };
// TODO: Integrate with Zettle payment setFormData(updated);
alert('Checkout functionality coming soon! For now, please contact us to complete your purchase.'); const required = ['email', 'firstName', 'lastName', 'address', 'city', 'postcode'] as const;
setFormValid(required.every(f => updated[f].trim() !== ''));
}; };
const currency = items[0]?.artwork?.currency || 'GBP';
const currencySymbol = currency === 'GBP' ? '\u00a3' : '$';
const shippingCost = getShippingCost(formData.country, currency);
const total = subtotal + shippingCost;
const paypalItems = items.map(item => ({
artworkId: parseInt(item.id),
artworkName: item.title,
price: item.price,
currency: item.artwork.currency || 'GBP',
quantity: item.quantity,
}));
return ( return (
<div className="min-h-screen bg-white pt-24"> <div className="min-h-screen bg-white pt-24">
{/* Header */} {/* Header */}
@ -48,6 +68,12 @@ export default function CheckoutPage() {
</div> </div>
<div className="mx-auto max-w-7xl px-4 py-12"> <div className="mx-auto max-w-7xl px-4 py-12">
{error && (
<div className="mb-8 bg-red-50 border border-red-200 text-red-700 px-4 py-3 text-sm">
{error}
</div>
)}
<div className="grid gap-12 lg:grid-cols-2"> <div className="grid gap-12 lg:grid-cols-2">
{/* Order Form */} {/* Order Form */}
<div> <div>
@ -55,7 +81,7 @@ export default function CheckoutPage() {
Contact Information Contact Information
</h2> </h2>
<form onSubmit={handleSubmit} className="space-y-6"> <div className="space-y-6">
<div> <div>
<label htmlFor="email" className="block text-sm text-gray-600 mb-2"> <label htmlFor="email" className="block text-sm text-gray-600 mb-2">
Email Address Email Address
@ -65,7 +91,7 @@ export default function CheckoutPage() {
id="email" id="email"
required required
value={formData.email} value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })} onChange={(e) => handleFieldChange('email', e.target.value)}
className="w-full border border-gray-300 px-4 py-3 text-sm focus:border-gray-900 focus:outline-none" className="w-full border border-gray-300 px-4 py-3 text-sm focus:border-gray-900 focus:outline-none"
/> />
</div> </div>
@ -80,7 +106,7 @@ export default function CheckoutPage() {
id="firstName" id="firstName"
required required
value={formData.firstName} value={formData.firstName}
onChange={(e) => setFormData({ ...formData, firstName: e.target.value })} onChange={(e) => handleFieldChange('firstName', e.target.value)}
className="w-full border border-gray-300 px-4 py-3 text-sm focus:border-gray-900 focus:outline-none" className="w-full border border-gray-300 px-4 py-3 text-sm focus:border-gray-900 focus:outline-none"
/> />
</div> </div>
@ -93,7 +119,7 @@ export default function CheckoutPage() {
id="lastName" id="lastName"
required required
value={formData.lastName} value={formData.lastName}
onChange={(e) => setFormData({ ...formData, lastName: e.target.value })} onChange={(e) => handleFieldChange('lastName', e.target.value)}
className="w-full border border-gray-300 px-4 py-3 text-sm focus:border-gray-900 focus:outline-none" className="w-full border border-gray-300 px-4 py-3 text-sm focus:border-gray-900 focus:outline-none"
/> />
</div> </div>
@ -112,7 +138,7 @@ export default function CheckoutPage() {
id="address" id="address"
required required
value={formData.address} value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })} onChange={(e) => handleFieldChange('address', e.target.value)}
className="w-full border border-gray-300 px-4 py-3 text-sm focus:border-gray-900 focus:outline-none" className="w-full border border-gray-300 px-4 py-3 text-sm focus:border-gray-900 focus:outline-none"
/> />
</div> </div>
@ -127,7 +153,7 @@ export default function CheckoutPage() {
id="city" id="city"
required required
value={formData.city} value={formData.city}
onChange={(e) => setFormData({ ...formData, city: e.target.value })} onChange={(e) => handleFieldChange('city', e.target.value)}
className="w-full border border-gray-300 px-4 py-3 text-sm focus:border-gray-900 focus:outline-none" className="w-full border border-gray-300 px-4 py-3 text-sm focus:border-gray-900 focus:outline-none"
/> />
</div> </div>
@ -140,7 +166,7 @@ export default function CheckoutPage() {
id="postcode" id="postcode"
required required
value={formData.postcode} value={formData.postcode}
onChange={(e) => setFormData({ ...formData, postcode: e.target.value })} onChange={(e) => handleFieldChange('postcode', e.target.value)}
className="w-full border border-gray-300 px-4 py-3 text-sm focus:border-gray-900 focus:outline-none" className="w-full border border-gray-300 px-4 py-3 text-sm focus:border-gray-900 focus:outline-none"
/> />
</div> </div>
@ -153,7 +179,7 @@ export default function CheckoutPage() {
<select <select
id="country" id="country"
value={formData.country} value={formData.country}
onChange={(e) => setFormData({ ...formData, country: e.target.value })} onChange={(e) => handleFieldChange('country', e.target.value)}
className="w-full border border-gray-300 px-4 py-3 text-sm focus:border-gray-900 focus:outline-none bg-white" className="w-full border border-gray-300 px-4 py-3 text-sm focus:border-gray-900 focus:outline-none bg-white"
> >
<option>United Kingdom</option> <option>United Kingdom</option>
@ -173,7 +199,7 @@ export default function CheckoutPage() {
type="tel" type="tel"
id="phone" id="phone"
value={formData.phone} value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })} onChange={(e) => handleFieldChange('phone', e.target.value)}
className="w-full border border-gray-300 px-4 py-3 text-sm focus:border-gray-900 focus:outline-none" className="w-full border border-gray-300 px-4 py-3 text-sm focus:border-gray-900 focus:outline-none"
/> />
</div> </div>
@ -186,24 +212,37 @@ export default function CheckoutPage() {
id="notes" id="notes"
rows={3} rows={3}
value={formData.notes} value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })} onChange={(e) => handleFieldChange('notes', e.target.value)}
className="w-full border border-gray-300 px-4 py-3 text-sm focus:border-gray-900 focus:outline-none resize-none" className="w-full border border-gray-300 px-4 py-3 text-sm focus:border-gray-900 focus:outline-none resize-none"
placeholder="Any special instructions..." placeholder="Any special instructions..."
/> />
</div> </div>
<div className="pt-6"> <div className="pt-6">
<button {formValid ? (
type="submit" <PayPalCheckout
className="w-full btn btn-primary py-4" items={paypalItems}
> shipping={formData}
Proceed to Payment shippingCost={shippingCost}
</button> currency={currency}
<p className="mt-4 text-center text-xs text-gray-500"> onSuccess={(orderId) => {
Secure payment powered by Zettle clearCart();
router.push(`/order-confirmation?id=${orderId}`);
}}
onError={(message) => {
setError(message);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
/>
) : (
<div className="text-center py-4">
<p className="text-sm text-gray-500">
Please fill in all required fields above to proceed to payment.
</p> </p>
</div> </div>
</form> )}
</div>
</div>
</div> </div>
{/* Order Summary */} {/* Order Summary */}
@ -231,7 +270,9 @@ export default function CheckoutPage() {
{item.artwork.medium && ( {item.artwork.medium && (
<p className="text-xs text-gray-500 mt-1">{item.artwork.medium}</p> <p className="text-xs text-gray-500 mt-1">{item.artwork.medium}</p>
)} )}
<p className="text-sm font-medium mt-2">{item.artwork.currency === 'GBP' ? '£' : '$'}{item.price.toLocaleString()}</p> <p className="text-sm font-medium mt-2">
{item.artwork.currency === 'GBP' ? '\u00a3' : '$'}{item.price.toLocaleString()}
</p>
<button <button
onClick={() => removeItem(item.id)} onClick={() => removeItem(item.id)}
className="text-xs text-gray-500 underline mt-2 hover:no-underline" className="text-xs text-gray-500 underline mt-2 hover:no-underline"
@ -246,22 +287,19 @@ export default function CheckoutPage() {
<div className="border-t border-gray-200 mt-6 pt-6 space-y-2"> <div className="border-t border-gray-200 mt-6 pt-6 space-y-2">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span>Subtotal</span> <span>Subtotal</span>
<span>${subtotal.toLocaleString()}</span> <span>{currencySymbol}{subtotal.toLocaleString()}</span>
</div> </div>
<div className="flex justify-between text-sm text-gray-500"> <div className="flex justify-between text-sm text-gray-500">
<span>Shipping</span> <span>Shipping</span>
<span>Calculated at next step</span> <span>{currencySymbol}{shippingCost.toLocaleString()}</span>
</div> </div>
</div> </div>
<div className="border-t border-gray-200 mt-6 pt-6"> <div className="border-t border-gray-200 mt-6 pt-6">
<div className="flex justify-between text-lg font-medium"> <div className="flex justify-between text-lg font-medium">
<span>Total</span> <span>Total</span>
<span>${subtotal.toLocaleString()}</span> <span>{currencySymbol}{total.toLocaleString()}</span>
</div> </div>
<p className="mt-1 text-xs text-gray-500">
+ shipping (calculated at next step)
</p>
</div> </div>
</div> </div>

View File

@ -0,0 +1,139 @@
import Link from 'next/link';
import { createDirectus, rest, staticToken, readItem, readItems } from '@directus/sdk';
import type { Order, OrderItem } from '@/lib/directus';
const directusUrl = process.env.DIRECTUS_INTERNAL_URL || process.env.NEXT_PUBLIC_DIRECTUS_URL || 'https://katheryn-cms.jeffemmett.com';
const storeToken = process.env.DIRECTUS_STORE_TOKEN || process.env.DIRECTUS_API_TOKEN || '';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = createDirectus<any>(directusUrl)
.with(staticToken(storeToken))
.with(rest());
export default async function OrderConfirmationPage({
searchParams,
}: {
searchParams: Promise<{ id?: string }>;
}) {
const params = await searchParams;
const orderId = params.id;
if (!orderId) {
return (
<div className="min-h-screen flex flex-col items-center justify-center py-20 pt-32">
<h1 className="font-serif text-2xl">Order not found</h1>
<Link href="/store" className="mt-8 btn btn-primary">
Browse Store
</Link>
</div>
);
}
let order: Order;
let orderItems: OrderItem[];
try {
order = await client.request(readItem('orders', orderId)) as Order;
orderItems = await client.request(
readItems('order_items', {
filter: { order_id: { _eq: parseInt(orderId) } },
fields: ['*'],
})
) as OrderItem[];
} catch {
return (
<div className="min-h-screen flex flex-col items-center justify-center py-20 pt-32">
<h1 className="font-serif text-2xl">Order not found</h1>
<p className="mt-4 text-gray-600">We couldn&apos;t find this order. Please check your email for confirmation.</p>
<Link href="/store" className="mt-8 btn btn-primary">
Browse Store
</Link>
</div>
);
}
const currencySymbol = order.currency === 'GBP' ? '\u00a3' : '$';
const customerName = [order.customer_first_name, order.customer_last_name].filter(Boolean).join(' ') || 'there';
return (
<div className="min-h-screen bg-white pt-24">
<div className="mx-auto max-w-2xl px-4 py-16 text-center">
<div className="mb-8">
<div className="mx-auto h-16 w-16 rounded-full bg-green-100 flex items-center justify-center mb-6">
<svg className="h-8 w-8 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h1 className="font-serif text-3xl">Thank you, {customerName}!</h1>
<p className="mt-4 text-gray-600">
Your order has been confirmed and a receipt has been sent to {order.customer_email}.
</p>
</div>
<div className="bg-gray-50 p-8 text-left mt-12">
<div className="flex justify-between items-start mb-6">
<div>
<p className="text-sm text-gray-500 uppercase tracking-wider">Order Number</p>
<p className="text-lg font-medium">#{order.id}</p>
</div>
<div className="text-right">
<p className="text-sm text-gray-500 uppercase tracking-wider">Total</p>
<p className="text-lg font-medium">{currencySymbol}{Number(order.total).toLocaleString()}</p>
</div>
</div>
<div className="border-t border-gray-200 pt-6">
<h2 className="text-sm font-medium uppercase tracking-wider text-gray-500 mb-4">Items</h2>
<ul className="space-y-3">
{orderItems.map((item) => (
<li key={item.id} className="flex justify-between text-sm">
<span>{item.artwork_name}</span>
<span className="font-medium">
{(item.currency === 'GBP' ? '\u00a3' : '$')}{Number(item.price).toLocaleString()}
</span>
</li>
))}
</ul>
</div>
<div className="border-t border-gray-200 pt-6 mt-6 space-y-2">
<div className="flex justify-between text-sm">
<span>Subtotal</span>
<span>{currencySymbol}{Number(order.subtotal).toLocaleString()}</span>
</div>
{order.shipping_cost != null && Number(order.shipping_cost) > 0 && (
<div className="flex justify-between text-sm">
<span>Shipping</span>
<span>{currencySymbol}{Number(order.shipping_cost).toLocaleString()}</span>
</div>
)}
<div className="flex justify-between text-sm font-medium border-t border-gray-200 pt-2">
<span>Total</span>
<span>{currencySymbol}{Number(order.total).toLocaleString()}</span>
</div>
</div>
{(order.shipping_address || order.shipping_city) && (
<div className="border-t border-gray-200 pt-6 mt-6">
<h2 className="text-sm font-medium uppercase tracking-wider text-gray-500 mb-2">Shipping To</h2>
<p className="text-sm text-gray-700">
{[order.shipping_address, order.shipping_city, order.shipping_postcode, order.shipping_country]
.filter(Boolean)
.join(', ')}
</p>
</div>
)}
</div>
<div className="mt-8 space-y-4">
<p className="text-sm text-gray-500">
Katheryn will be in touch regarding delivery details.
</p>
<Link href="/store" className="inline-block btn btn-primary">
Continue Shopping
</Link>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,132 @@
'use client';
import { PayPalScriptProvider, PayPalButtons } from '@paypal/react-paypal-js';
import { useState, useRef } from 'react';
interface CartItemForPayPal {
artworkId: number;
artworkName: string;
price: number;
currency: string;
quantity: number;
}
interface ShippingInfo {
email: string;
firstName: string;
lastName: string;
address: string;
city: string;
postcode: string;
country: string;
phone?: string;
notes?: string;
}
interface PayPalCheckoutProps {
items: CartItemForPayPal[];
shipping: ShippingInfo;
shippingCost: number;
currency: string;
onSuccess: (orderId: number) => void;
onError: (message: string) => void;
}
export function PayPalCheckout({ items, shipping, shippingCost, currency, onSuccess, onError }: PayPalCheckoutProps) {
const [processing, setProcessing] = useState(false);
const internalOrderId = useRef<number | null>(null);
const clientId = process.env.NEXT_PUBLIC_PAYPAL_CLIENT_ID || '';
if (!clientId) {
return (
<div className="text-center py-4 text-gray-500">
Payment system is being configured. Please check back soon.
</div>
);
}
return (
<div className="relative">
{processing && (
<div className="absolute inset-0 bg-white/80 flex items-center justify-center z-10">
<p className="text-sm text-gray-600">Processing your payment...</p>
</div>
)}
<PayPalScriptProvider options={{
clientId,
currency: currency,
intent: 'capture',
}}>
<PayPalButtons
style={{
layout: 'vertical',
color: 'black',
shape: 'rect',
label: 'pay',
}}
disabled={processing}
createOrder={async () => {
try {
const res = await fetch('/api/orders/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items, shipping, shippingCost }),
});
const data = await res.json();
if (!res.ok) {
onError(data.error || 'Failed to create order');
throw new Error(data.error);
}
internalOrderId.current = data.orderId;
return data.paypalOrderId;
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create order';
onError(message);
throw err;
}
}}
onApprove={async (data) => {
setProcessing(true);
try {
const res = await fetch('/api/orders/capture', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
paypalOrderId: data.orderID,
orderId: internalOrderId.current,
}),
});
const result = await res.json();
if (!res.ok) {
onError(result.error || 'Payment capture failed');
return;
}
onSuccess(result.orderId);
} catch (err) {
onError('Something went wrong processing your payment. Please contact us.');
console.error('PayPal capture error:', err);
} finally {
setProcessing(false);
}
}}
onError={(err) => {
console.error('PayPal error:', err);
onError('PayPal encountered an error. Please try again.');
}}
onCancel={() => {
// User cancelled - no action needed
}}
/>
</PayPalScriptProvider>
<p className="mt-3 text-center text-xs text-gray-500">
Secure payment powered by PayPal
</p>
</div>
);
}

View File

@ -0,0 +1,87 @@
import { createDirectus, rest, staticToken, createItem, createItems, updateItem, readItem } from '@directus/sdk';
const DIRECTUS_INTERNAL_URL = process.env.DIRECTUS_INTERNAL_URL || 'http://katheryn-cms:8055';
const STORE_TOKEN = process.env.DIRECTUS_STORE_TOKEN || '';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const adminClient = createDirectus<any>(DIRECTUS_INTERNAL_URL)
.with(staticToken(STORE_TOKEN))
.with(rest());
export interface OrderData {
customer_email: string;
customer_first_name?: string;
customer_last_name?: string;
shipping_address?: string;
shipping_city?: string;
shipping_postcode?: string;
shipping_country?: string;
phone?: string;
notes?: string;
subtotal: number;
shipping_cost?: number;
total: number;
currency: string;
paypal_order_id?: string;
status?: string;
}
export interface OrderItemData {
order_id: number;
artwork_id: number;
artwork_name: string;
price: number;
currency: string;
quantity: number;
}
export async function createOrder(data: OrderData): Promise<{ id: number }> {
const result = await adminClient.request(
createItem('orders', { ...data, status: data.status || 'pending' })
);
return result as { id: number };
}
export async function createOrderItems(items: OrderItemData[]): Promise<void> {
await adminClient.request(createItems('order_items', items));
}
export async function updateOrderStatus(
orderId: number,
status: string,
paypalData?: { paypal_capture_id?: string; paypal_payer_email?: string },
): Promise<void> {
await adminClient.request(
updateItem('orders', orderId, {
status,
...paypalData,
date_updated: new Date().toISOString(),
})
);
}
export async function updateOrder(
orderId: number,
data: Record<string, unknown>,
): Promise<void> {
await adminClient.request(
updateItem('orders', orderId, { ...data, date_updated: new Date().toISOString() })
);
}
export async function updateArtworkStatus(artworkId: number, status: string): Promise<void> {
await adminClient.request(
updateItem('artworks', artworkId, { status })
);
}
export async function getArtworkPrice(artworkId: number): Promise<{
price_gbp: string | null;
price_usd: string | null;
status: string;
}> {
const result = await adminClient.request(
readItem('artworks', artworkId, { fields: ['price_gbp', 'price_usd', 'status'] })
);
return result as { price_gbp: string | null; price_usd: string | null; status: string };
}

112
frontend/src/lib/email.ts Normal file
View File

@ -0,0 +1,112 @@
import nodemailer from 'nodemailer';
const SMTP_HOST = process.env.SMTP_HOST || 'mx.jeffemmett.com';
const SMTP_PORT = parseInt(process.env.SMTP_PORT || '587');
const SMTP_USER = process.env.SMTP_USER || 'orders@katheryntrenshaw.com';
const SMTP_PASS = process.env.SMTP_PASS || '';
const FROM_EMAIL = process.env.SMTP_FROM || 'orders@katheryntrenshaw.com';
const KATHERYN_EMAIL = 'katheryn@katheryntrenshaw.com';
const transporter = nodemailer.createTransport({
host: SMTP_HOST,
port: SMTP_PORT,
secure: false, // STARTTLS on 587
auth: {
user: SMTP_USER,
pass: SMTP_PASS,
},
tls: {
rejectUnauthorized: false,
},
});
interface OrderEmailData {
orderId: number;
customerEmail: string;
customerName: string;
items: Array<{ name: string; price: number; currency: string }>;
subtotal: number;
shippingCost: number;
total: number;
currency: string;
shippingAddress?: string;
shippingCity?: string;
shippingPostcode?: string;
shippingCountry?: string;
}
export async function sendOrderConfirmation(data: OrderEmailData): Promise<void> {
if (!SMTP_PASS) {
console.warn('SMTP_PASS not set, skipping order confirmation email');
return;
}
const currencySymbol = data.currency === 'GBP' ? '\u00a3' : '$';
const itemsHtml = data.items
.map(item => `<li>${item.name} - ${currencySymbol}${item.price.toFixed(2)}</li>`)
.join('\n');
const addressLines = [data.shippingAddress, data.shippingCity, data.shippingPostcode, data.shippingCountry]
.filter(Boolean)
.join(', ');
const customerHtml = `
<div style="font-family: Georgia, serif; max-width: 600px; margin: 0 auto; color: #333;">
<h1 style="font-size: 24px; font-weight: normal;">Thank you for your order</h1>
<p>Dear ${data.customerName},</p>
<p>Your order #${data.orderId} has been confirmed.</p>
<h2 style="font-size: 18px; border-bottom: 1px solid #ddd; padding-bottom: 8px;">Order Summary</h2>
<ul style="list-style: none; padding: 0;">${itemsHtml}</ul>
<div style="border-top: 1px solid #ddd; padding-top: 12px;">
<p style="font-size: 14px; margin: 4px 0;">Subtotal: ${currencySymbol}${data.subtotal.toFixed(2)}</p>
${data.shippingCost > 0 ? `<p style="font-size: 14px; margin: 4px 0;">Shipping: ${currencySymbol}${data.shippingCost.toFixed(2)}</p>` : ''}
<p style="font-size: 18px; font-weight: bold; margin-top: 8px;">
Total: ${currencySymbol}${data.total.toFixed(2)}
</p>
</div>
${addressLines ? `<p><strong>Shipping to:</strong> ${addressLines}</p>` : ''}
<p style="margin-top: 24px; color: #666; font-size: 14px;">
Katheryn will be in touch regarding shipping details.<br/>
If you have any questions, please reply to this email.
</p>
<p style="margin-top: 32px;">With gratitude,<br/>Katheryn Trenshaw</p>
</div>
`;
const notificationHtml = `
<div style="font-family: sans-serif; max-width: 600px;">
<h2>New Order #${data.orderId}</h2>
<p><strong>Customer:</strong> ${data.customerName} (${data.customerEmail})</p>
<p><strong>Subtotal:</strong> ${currencySymbol}${data.subtotal.toFixed(2)}</p>
${data.shippingCost > 0 ? `<p><strong>Shipping:</strong> ${currencySymbol}${data.shippingCost.toFixed(2)}</p>` : ''}
<p><strong>Total:</strong> ${currencySymbol}${data.total.toFixed(2)}</p>
<h3>Items:</h3>
<ul>${itemsHtml}</ul>
${addressLines ? `<p><strong>Ship to:</strong> ${addressLines}</p>` : ''}
</div>
`;
// Send confirmation to customer
try {
await transporter.sendMail({
from: `"Katheryn Trenshaw" <${FROM_EMAIL}>`,
to: data.customerEmail,
subject: `Order Confirmation #${data.orderId} - Katheryn Trenshaw`,
html: customerHtml,
});
} catch (err) {
console.error(`Failed to send customer email to ${data.customerEmail}:`, err);
}
// Send notification to Katheryn
try {
await transporter.sendMail({
from: `"Katheryn Store" <${FROM_EMAIL}>`,
to: KATHERYN_EMAIL,
subject: `New Order #${data.orderId} from ${data.customerName}`,
html: notificationHtml,
});
} catch (err) {
console.error(`Failed to send notification email to ${KATHERYN_EMAIL}:`, err);
}
}

105
frontend/src/lib/paypal.ts Normal file
View File

@ -0,0 +1,105 @@
const PAYPAL_CLIENT_ID = process.env.PAYPAL_CLIENT_ID!;
const PAYPAL_CLIENT_SECRET = process.env.PAYPAL_CLIENT_SECRET!;
const PAYPAL_MODE = process.env.PAYPAL_MODE || 'sandbox';
const PAYPAL_BASE_URL = PAYPAL_MODE === 'live'
? 'https://api-m.paypal.com'
: 'https://api-m.sandbox.paypal.com';
export async function getPayPalAccessToken(): Promise<string> {
const auth = Buffer.from(`${PAYPAL_CLIENT_ID}:${PAYPAL_CLIENT_SECRET}`).toString('base64');
const res = await fetch(`${PAYPAL_BASE_URL}/v1/oauth2/token`, {
method: 'POST',
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'grant_type=client_credentials',
});
if (!res.ok) {
const text = await res.text();
throw new Error(`PayPal auth failed: ${res.status} ${text}`);
}
const data = await res.json();
return data.access_token;
}
export interface PayPalItem {
name: string;
unit_amount: { currency_code: string; value: string };
quantity: string;
}
export async function createPayPalOrder(
amount: number,
currency: string,
items: PayPalItem[],
shippingCost: number,
referenceId?: string,
): Promise<{ id: string; status: string }> {
const accessToken = await getPayPalAccessToken();
const itemTotal = (amount - shippingCost).toFixed(2);
const res = await fetch(`${PAYPAL_BASE_URL}/v2/checkout/orders`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
intent: 'CAPTURE',
purchase_units: [{
reference_id: referenceId,
amount: {
currency_code: currency,
value: amount.toFixed(2),
breakdown: {
item_total: { currency_code: currency, value: itemTotal },
shipping: { currency_code: currency, value: shippingCost.toFixed(2) },
},
},
items,
}],
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`PayPal create order failed: ${res.status} ${text}`);
}
return res.json();
}
export async function capturePayPalOrder(
orderId: string,
): Promise<{
id: string;
status: string;
purchase_units: Array<{
payments: {
captures: Array<{ id: string; status: string }>;
};
}>;
payer: { email_address: string };
}> {
const accessToken = await getPayPalAccessToken();
const res = await fetch(`${PAYPAL_BASE_URL}/v2/checkout/orders/${orderId}/capture`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
});
if (!res.ok) {
const text = await res.text();
throw new Error(`PayPal capture failed: ${res.status} ${text}`);
}
return res.json();
}

View File

@ -0,0 +1,48 @@
export type ShippingRegion = 'uk' | 'europe' | 'international';
export interface ShippingRate {
region: ShippingRegion;
label: string;
cost_gbp: number;
cost_usd: number;
}
export const SHIPPING_RATES: Record<ShippingRegion, ShippingRate> = {
uk: {
region: 'uk',
label: 'United Kingdom',
cost_gbp: 10,
cost_usd: 13,
},
europe: {
region: 'europe',
label: 'Europe',
cost_gbp: 25,
cost_usd: 32,
},
international: {
region: 'international',
label: 'International',
cost_gbp: 40,
cost_usd: 52,
},
};
const COUNTRY_REGION_MAP: Record<string, ShippingRegion> = {
'United Kingdom': 'uk',
'Ireland': 'europe',
'France': 'europe',
'Germany': 'europe',
'United States': 'international',
'Other': 'international',
};
export function getShippingRegion(country: string): ShippingRegion {
return COUNTRY_REGION_MAP[country] || 'international';
}
export function getShippingCost(country: string, currency: string): number {
const region = getShippingRegion(country);
const rate = SHIPPING_RATES[region];
return currency === 'GBP' ? rate.cost_gbp : rate.cost_usd;
}