From 2b5f2cf91d29f41deb1cb63e63b06dca89d91668 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 13 Feb 2026 12:30:36 -0700 Subject: [PATCH] Add flat-rate shipping, PayPal checkout, and order confirmation emails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/app/api/orders/capture/route.ts | 102 ++++++++++++++ frontend/src/app/api/orders/create/route.ts | 129 +++++++++++++++++ frontend/src/app/checkout/page.tsx | 102 +++++++++----- frontend/src/app/order-confirmation/page.tsx | 139 +++++++++++++++++++ frontend/src/components/paypal-checkout.tsx | 132 ++++++++++++++++++ frontend/src/lib/directus-admin.ts | 87 ++++++++++++ frontend/src/lib/email.ts | 112 +++++++++++++++ frontend/src/lib/paypal.ts | 105 ++++++++++++++ frontend/src/lib/shipping.ts | 48 +++++++ 9 files changed, 924 insertions(+), 32 deletions(-) create mode 100644 frontend/src/app/api/orders/capture/route.ts create mode 100644 frontend/src/app/api/orders/create/route.ts create mode 100644 frontend/src/app/order-confirmation/page.tsx create mode 100644 frontend/src/components/paypal-checkout.tsx create mode 100644 frontend/src/lib/directus-admin.ts create mode 100644 frontend/src/lib/email.ts create mode 100644 frontend/src/lib/paypal.ts create mode 100644 frontend/src/lib/shipping.ts diff --git a/frontend/src/app/api/orders/capture/route.ts b/frontend/src/app/api/orders/capture/route.ts new file mode 100644 index 0000000..3b19a26 --- /dev/null +++ b/frontend/src/app/api/orders/capture/route.ts @@ -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(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 }, + ); + } +} diff --git a/frontend/src/app/api/orders/create/route.ts b/frontend/src/app/api/orders/create/route.ts new file mode 100644 index 0000000..2ee7702 --- /dev/null +++ b/frontend/src/app/api/orders/create/route.ts @@ -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 }, + ); + } +} diff --git a/frontend/src/app/checkout/page.tsx b/frontend/src/app/checkout/page.tsx index 3101606..18824df 100644 --- a/frontend/src/app/checkout/page.tsx +++ b/frontend/src/app/checkout/page.tsx @@ -3,11 +3,17 @@ import { useState } from 'react'; import Link from 'next/link'; import Image from 'next/image'; +import { useRouter } from 'next/navigation'; import { useCart } from '@/context/cart-context'; import { getAssetUrl } from '@/lib/directus'; +import { PayPalCheckout } from '@/components/paypal-checkout'; +import { getShippingCost } from '@/lib/shipping'; export default function CheckoutPage() { - const { items, subtotal, removeItem, itemCount } = useCart(); + const { items, subtotal, removeItem, itemCount, clearCart } = useCart(); + const router = useRouter(); + const [error, setError] = useState(null); + const [formValid, setFormValid] = useState(false); const [formData, setFormData] = useState({ email: '', firstName: '', @@ -32,12 +38,26 @@ export default function CheckoutPage() { ); } - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - // TODO: Integrate with Zettle payment - alert('Checkout functionality coming soon! For now, please contact us to complete your purchase.'); + const handleFieldChange = (field: string, value: string) => { + const updated = { ...formData, [field]: value }; + setFormData(updated); + 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 (
{/* Header */} @@ -48,6 +68,12 @@ export default function CheckoutPage() {
+ {error && ( +
+ {error} +
+ )} +
{/* Order Form */}
@@ -55,7 +81,7 @@ export default function CheckoutPage() { Contact Information -
+
@@ -80,7 +106,7 @@ export default function CheckoutPage() { id="firstName" required 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" />
@@ -93,7 +119,7 @@ export default function CheckoutPage() { id="lastName" required 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" />
@@ -112,7 +138,7 @@ export default function CheckoutPage() { id="address" required 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" />
@@ -127,7 +153,7 @@ export default function CheckoutPage() { id="city" required 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" />
@@ -140,7 +166,7 @@ export default function CheckoutPage() { id="postcode" required 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" /> @@ -153,7 +179,7 @@ export default function CheckoutPage() {