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:
parent
462b34f114
commit
2b5f2cf91d
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string | null>(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 (
|
||||
<div className="min-h-screen bg-white pt-24">
|
||||
{/* Header */}
|
||||
|
|
@ -48,6 +68,12 @@ export default function CheckoutPage() {
|
|||
</div>
|
||||
|
||||
<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">
|
||||
{/* Order Form */}
|
||||
<div>
|
||||
|
|
@ -55,7 +81,7 @@ export default function CheckoutPage() {
|
|||
Contact Information
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm text-gray-600 mb-2">
|
||||
Email Address
|
||||
|
|
@ -65,7 +91,7 @@ export default function CheckoutPage() {
|
|||
id="email"
|
||||
required
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -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"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -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"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -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"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -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"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -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"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -153,7 +179,7 @@ export default function CheckoutPage() {
|
|||
<select
|
||||
id="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"
|
||||
>
|
||||
<option>United Kingdom</option>
|
||||
|
|
@ -173,7 +199,7 @@ export default function CheckoutPage() {
|
|||
type="tel"
|
||||
id="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"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -186,24 +212,37 @@ export default function CheckoutPage() {
|
|||
id="notes"
|
||||
rows={3}
|
||||
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"
|
||||
placeholder="Any special instructions..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-6">
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full btn btn-primary py-4"
|
||||
>
|
||||
Proceed to Payment
|
||||
</button>
|
||||
<p className="mt-4 text-center text-xs text-gray-500">
|
||||
Secure payment powered by Zettle
|
||||
</p>
|
||||
{formValid ? (
|
||||
<PayPalCheckout
|
||||
items={paypalItems}
|
||||
shipping={formData}
|
||||
shippingCost={shippingCost}
|
||||
currency={currency}
|
||||
onSuccess={(orderId) => {
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order Summary */}
|
||||
|
|
@ -231,7 +270,9 @@ export default function CheckoutPage() {
|
|||
{item.artwork.medium && (
|
||||
<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
|
||||
onClick={() => removeItem(item.id)}
|
||||
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="flex justify-between text-sm">
|
||||
<span>Subtotal</span>
|
||||
<span>${subtotal.toLocaleString()}</span>
|
||||
<span>{currencySymbol}{subtotal.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-gray-500">
|
||||
<span>Shipping</span>
|
||||
<span>Calculated at next step</span>
|
||||
<span>{currencySymbol}{shippingCost.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 mt-6 pt-6">
|
||||
<div className="flex justify-between text-lg font-medium">
|
||||
<span>Total</span>
|
||||
<span>${subtotal.toLocaleString()}</span>
|
||||
<span>{currencySymbol}{total.toLocaleString()}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
+ shipping (calculated at next step)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue