diff --git a/backend/src/app.ts b/backend/src/app.ts index 354015a..5fb0832 100755 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -2,18 +2,20 @@ import express from 'express'; import cookieParser from 'cookie-parser'; import path from 'path'; import itemsRouter from './routes/items'; -import { router as paypalRouter, webhookRouter as paypalWebhookRouter } from './routes/paypal'; +import { router as cartCheckoutRouter, webhookRouter as cartCheckoutWebhookRouter } from './routes/cartCheckout'; import adminRouter from './routes/admin'; import adminCustomersRouter from './routes/adminCustomers'; -import demoRouter from './routes/demo'; +import adminSettingsRouter from './routes/adminSettings'; import customersRouter from './routes/customers'; import publicRouter from './routes/public'; +import cartRouter from './routes/cart'; +import shippingAddressesRouter from './routes/shippingAddresses'; import { attachCustomer } from './middleware/customerAuth'; const app = express(); app.set('trust proxy', 1); -app.use('/webhooks/paypal', express.json(), paypalWebhookRouter); +app.use('/webhooks/paypal', express.json(), cartCheckoutWebhookRouter); app.use(express.json()); app.use(cookieParser()); app.use(attachCustomer); @@ -30,15 +32,15 @@ app.get('/api/config', (_req, res) => { }); app.use('/api/items', itemsRouter); -app.use('/api/checkout/paypal', paypalRouter); -app.use('/api/checkout/demo', demoRouter); +app.use('/api/cart', cartRouter); +app.use('/api/checkout/cart', cartCheckoutRouter); app.use('/api/admin/customers', adminCustomersRouter); +app.use('/api/admin/settings', adminSettingsRouter); app.use('/api/admin', adminRouter); +app.use('/api/customers/me/addresses', shippingAddressesRouter); app.use('/api/customers', customersRouter); app.use('/', publicRouter); -// Skip serving the built frontend during tests — there's no /public dir yet -// at that point, and tests only care about the API surface. if (process.env.NODE_ENV !== 'test') { const staticDir = path.join(__dirname, '..', 'public'); app.use(express.static(staticDir)); diff --git a/backend/src/routes/adminSettings.ts b/backend/src/routes/adminSettings.ts new file mode 100644 index 0000000..def7528 --- /dev/null +++ b/backend/src/routes/adminSettings.ts @@ -0,0 +1,29 @@ +import { Router, Request, Response } from 'express'; +import { pool } from '../db'; + +const router = Router(); + +router.get('/', async (_req: Request, res: Response) => { + const { rows } = await pool.query(`SELECT key, value FROM admin_settings`); + const map: Record = {}; + for (const r of rows) map[r.key] = r.value; + res.json({ + cartExpiryHours: parseFloat(map.cart_expiry_hours || '24') + }); +}); + +router.put('/', async (req: Request, res: Response) => { + const { cartExpiryHours } = req.body; + const hours = parseFloat(cartExpiryHours); + if (Number.isNaN(hours) || hours <= 0) { + return res.status(400).json({ error: 'cartExpiryHours must be a positive number' }); + } + await pool.query( + `INSERT INTO admin_settings (key, value, updated_at) VALUES ('cart_expiry_hours', $1, now()) + ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = now()`, + [String(hours)] + ); + res.json({ cartExpiryHours: hours }); +}); + +export default router; diff --git a/backend/src/routes/cart.ts b/backend/src/routes/cart.ts new file mode 100644 index 0000000..6e36362 --- /dev/null +++ b/backend/src/routes/cart.ts @@ -0,0 +1,108 @@ +import { Router, Request, Response } from 'express'; +import { pool } from '../db'; +import { requireCustomer } from '../middleware/customerAuth'; + +const router = Router(); + +async function getCartExpiryHours(): Promise { + const { rows } = await pool.query(`SELECT value FROM admin_settings WHERE key = 'cart_expiry_hours'`); + return rows.length ? parseFloat(rows[0].value) : 24; +} + +const CART_ITEM_SELECT = ` + SELECT + ci.item_id, ci.added_at, ci.expires_at, + i.name, i.price_cents, i.status, + COALESCE( + json_agg(json_build_object('id', img.id, 'image_path', img.image_path) ORDER BY img.sort_order) + FILTER (WHERE img.id IS NOT NULL), + '[]' + ) AS images + FROM cart_items ci + JOIN items i ON i.id = ci.item_id + LEFT JOIN item_images img ON img.item_id = i.id + WHERE ci.cart_id = $1 + GROUP BY ci.item_id, ci.added_at, ci.expires_at, i.name, i.price_cents, i.status + ORDER BY ci.added_at DESC +`; + +router.get('/', requireCustomer, async (req: Request, res: Response) => { + const { rows: cartRows } = await pool.query(`SELECT id FROM carts WHERE customer_id = $1`, [req.customerId]); + if (!cartRows.length) return res.json({ items: [] }); + const { rows: items } = await pool.query(CART_ITEM_SELECT, [cartRows[0].id]); + res.json({ items }); +}); + +router.post('/items/:itemId', requireCustomer, async (req: Request, res: Response) => { + const itemId = req.params.itemId; + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const { rows: itemRows } = await client.query(`SELECT * FROM items WHERE id = $1 FOR UPDATE`, [itemId]); + const item = itemRows[0]; + if (!item) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'not found' }); } + if (item.status !== 'available') { + await client.query('ROLLBACK'); + return res.status(409).json({ error: 'item is no longer available' }); + } + + let { rows: cartRows } = await client.query(`SELECT id FROM carts WHERE customer_id = $1`, [req.customerId]); + let cartId: number; + if (cartRows.length) { + cartId = cartRows[0].id; + await client.query(`UPDATE carts SET updated_at = now() WHERE id = $1`, [cartId]); + } else { + const { rows: newCart } = await client.query( + `INSERT INTO carts (customer_id) VALUES ($1) RETURNING id`, + [req.customerId] + ); + cartId = newCart[0].id; + } + + const hours = await getCartExpiryHours(); + const expiresAt = new Date(Date.now() + hours * 60 * 60 * 1000); + await client.query( + `INSERT INTO cart_items (cart_id, item_id, expires_at) VALUES ($1, $2, $3)`, + [cartId, itemId, expiresAt] + ); + await client.query(`UPDATE items SET status = 'reserved' WHERE id = $1`, [itemId]); + await client.query('COMMIT'); + res.status(201).json({ itemId: parseInt(itemId, 10), expiresAt }); + } catch (err) { + await client.query('ROLLBACK'); + console.error(err); + res.status(500).json({ error: 'internal error' }); + } finally { + client.release(); + } +}); + +router.delete('/items/:itemId', requireCustomer, async (req: Request, res: Response) => { + const itemId = req.params.itemId; + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const { rows } = await client.query( + `DELETE FROM cart_items ci + USING carts c + WHERE ci.cart_id = c.id AND c.customer_id = $1 AND ci.item_id = $2 + RETURNING ci.item_id`, + [req.customerId, itemId] + ); + if (!rows.length) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'not in your cart' }); } + await client.query( + `UPDATE items SET status = 'available' WHERE id = $1 AND status = 'reserved'`, + [itemId] + ); + await client.query('COMMIT'); + res.status(204).end(); + } catch (err) { + await client.query('ROLLBACK'); + console.error(err); + res.status(500).json({ error: 'internal error' }); + } finally { + client.release(); + } +}); + +export default router; diff --git a/backend/src/routes/cartCheckout.ts b/backend/src/routes/cartCheckout.ts new file mode 100644 index 0000000..a276116 --- /dev/null +++ b/backend/src/routes/cartCheckout.ts @@ -0,0 +1,248 @@ +import { Router, Request, Response } from 'express'; +import { pool } from '../db'; +import { requireCustomer } from '../middleware/customerAuth'; + +const router = Router(); +const webhookRouter = Router(); + +const PAYPAL_BASE = + process.env.PAYPAL_ENV === 'live' + ? 'https://api-m.paypal.com' + : 'https://api-m.sandbox.paypal.com'; + +async function getAccessToken(): Promise { + const auth = Buffer.from(`${process.env.PAYPAL_CLIENT_ID}:${process.env.PAYPAL_CLIENT_SECRET}`).toString('base64'); + const resp = await fetch(`${PAYPAL_BASE}/v1/oauth2/token`, { + method: 'POST', + headers: { Authorization: `Basic ${auth}`, 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'grant_type=client_credentials' + }); + const data = await resp.json(); + if (!resp.ok) throw new Error('paypal auth failed: ' + JSON.stringify(data)); + return data.access_token; +} + +// Locks the customer's cart, verifies every item is still reserved to them, +// and returns { cartId, items: [{id, name, price_cents}], totalCents }. +async function loadLockedCart(client: any, customerId: number) { + const { rows: cartRows } = await client.query(`SELECT id FROM carts WHERE customer_id = $1`, [customerId]); + if (!cartRows.length) return null; + const cartId = cartRows[0].id; + const { rows: items } = await client.query( + `SELECT i.id, i.name, i.price_cents + FROM cart_items ci + JOIN items i ON i.id = ci.item_id + WHERE ci.cart_id = $1 + FOR UPDATE OF i`, + [cartId] + ); + if (!items.length) return { cartId, items: [], totalCents: 0 }; + const totalCents = items.reduce((sum: number, it: any) => sum + it.price_cents, 0); + return { cartId, items, totalCents }; +} + +router.post('/paypal/create', requireCustomer, async (req: Request, res: Response) => { + const { shippingAddressId } = req.body; + if (!shippingAddressId) return res.status(400).json({ error: 'shippingAddressId is required' }); + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const { rows: addrRows } = await client.query( + `SELECT id FROM shipping_addresses WHERE id = $1 AND customer_id = $2`, + [shippingAddressId, req.customerId] + ); + if (!addrRows.length) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'invalid shipping address' }); } + + const cart = await loadLockedCart(client, req.customerId as number); + if (!cart || !cart.items.length) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'cart is empty' }); } + + const { rows: checkoutRows } = await client.query( + `INSERT INTO checkouts (customer_id, shipping_address_id, processor, amount_cents, status) + VALUES ($1, $2, 'paypal', $3, 'pending') RETURNING id`, + [req.customerId, shippingAddressId, cart.totalCents] + ); + const checkoutId = checkoutRows[0].id; + for (const it of cart.items) { + await client.query( + `INSERT INTO checkout_items (checkout_id, item_id, price_cents) VALUES ($1, $2, $3)`, + [checkoutId, it.id, it.price_cents] + ); + } + + const token = await getAccessToken(); + const currency = process.env.SITE_CURRENCY || 'USD'; + const orderResp = await fetch(`${PAYPAL_BASE}/v2/checkout/orders`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + intent: 'CAPTURE', + purchase_units: [{ + custom_id: String(checkoutId), + amount: { + currency_code: currency, + value: (cart.totalCents / 100).toFixed(2), + breakdown: { item_total: { currency_code: currency, value: (cart.totalCents / 100).toFixed(2) } } + }, + items: cart.items.map((it: any) => ({ + name: it.name.slice(0, 127), + quantity: '1', + unit_amount: { currency_code: currency, value: (it.price_cents / 100).toFixed(2) } + })) + }] + }) + }); + const order = await orderResp.json(); + if (!orderResp.ok) { await client.query('ROLLBACK'); return res.status(502).json({ error: 'paypal order create failed', detail: order }); } + + await client.query(`UPDATE checkouts SET processor_order_id = $1 WHERE id = $2`, [order.id, checkoutId]); + await client.query('COMMIT'); + res.json({ orderID: order.id, checkoutId }); + } catch (err) { + await client.query('ROLLBACK'); + console.error(err); + res.status(500).json({ error: 'internal error' }); + } finally { + client.release(); + } +}); + +async function completeCheckout(client: any, checkoutId: number, processor: string, processorOrderId: string | null, rawEvent: unknown) { + const { rows: checkoutItems } = await client.query( + `SELECT item_id, price_cents FROM checkout_items WHERE checkout_id = $1`, + [checkoutId] + ); + const { rows: checkoutRows } = await client.query(`SELECT customer_id FROM checkouts WHERE id = $1`, [checkoutId]); + const customerId = checkoutRows[0]?.customer_id; + + for (const ci of checkoutItems) { + await client.query(`UPDATE items SET status = 'sold', sold_at = now() WHERE id = $1`, [ci.item_id]); + await client.query( + `INSERT INTO orders (item_id, customer_id, checkout_id, processor, processor_order_id, amount_cents, status, raw_event) + VALUES ($1, $2, $3, $4, $5, $6, 'completed', $7)`, + [ci.item_id, customerId, checkoutId, processor, processorOrderId, ci.price_cents, rawEvent] + ); + await client.query(`DELETE FROM cart_items WHERE item_id = $1`, [ci.item_id]); + } + await client.query(`UPDATE checkouts SET status = 'completed', raw_event = $1 WHERE id = $2`, [rawEvent, checkoutId]); +} + +router.post('/paypal/capture', requireCustomer, async (req: Request, res: Response) => { + const { orderID } = req.body; + const client = await pool.connect(); + try { + const token = await getAccessToken(); + const captureResp = await fetch(`${PAYPAL_BASE}/v2/checkout/orders/${orderID}/capture`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } + }); + const capture = await captureResp.json(); + if (!captureResp.ok || capture.status !== 'COMPLETED') { + return res.status(502).json({ error: 'capture failed', detail: capture }); + } + + const { rows } = await pool.query( + `SELECT id FROM checkouts WHERE processor_order_id = $1 AND customer_id = $2`, + [orderID, req.customerId] + ); + if (!rows.length) return res.status(404).json({ error: 'checkout not found' }); + + await client.query('BEGIN'); + await completeCheckout(client, rows[0].id, 'paypal', orderID, capture); + await client.query('COMMIT'); + res.json({ status: 'completed' }); + } catch (err) { + await client.query('ROLLBACK'); + console.error(err); + res.status(500).json({ error: 'internal error' }); + } finally { + client.release(); + } +}); + +router.post('/demo/purchase', requireCustomer, async (req: Request, res: Response) => { + if (process.env.DEMO_MODE === 'false') return res.status(403).json({ error: 'demo mode disabled' }); + const { shippingAddressId } = req.body; + if (!shippingAddressId) return res.status(400).json({ error: 'shippingAddressId is required' }); + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const { rows: addrRows } = await client.query( + `SELECT id FROM shipping_addresses WHERE id = $1 AND customer_id = $2`, + [shippingAddressId, req.customerId] + ); + if (!addrRows.length) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'invalid shipping address' }); } + + const cart = await loadLockedCart(client, req.customerId as number); + if (!cart || !cart.items.length) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'cart is empty' }); } + + const { rows: checkoutRows } = await client.query( + `INSERT INTO checkouts (customer_id, shipping_address_id, processor, processor_order_id, amount_cents, status) + VALUES ($1, $2, 'demo', $3, $4, 'pending') RETURNING id`, + [req.customerId, shippingAddressId, `demo-${Date.now()}`, cart.totalCents] + ); + const checkoutId = checkoutRows[0].id; + for (const it of cart.items) { + await client.query(`INSERT INTO checkout_items (checkout_id, item_id, price_cents) VALUES ($1, $2, $3)`, [checkoutId, it.id, it.price_cents]); + } + + await completeCheckout(client, checkoutId, 'demo', null, { demo: true }); + await client.query('COMMIT'); + res.json({ status: 'completed' }); + } catch (err) { + await client.query('ROLLBACK'); + console.error(err); + res.status(500).json({ error: 'internal error' }); + } finally { + client.release(); + } +}); + +webhookRouter.post('/', async (req: Request, res: Response) => { + try { + const token = await getAccessToken(); + const verifyResp = await fetch(`${PAYPAL_BASE}/v1/notifications/verify-webhook-signature`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + auth_algo: req.headers['paypal-auth-algo'], + cert_url: req.headers['paypal-cert-url'], + transmission_id: req.headers['paypal-transmission-id'], + transmission_sig: req.headers['paypal-transmission-sig'], + transmission_time: req.headers['paypal-transmission-time'], + webhook_id: process.env.PAYPAL_WEBHOOK_ID, + webhook_event: req.body + }) + }); + const verification = await verifyResp.json(); + if (verification.verification_status !== 'SUCCESS') return res.status(400).end(); + + const event = req.body; + if (event.event_type === 'PAYMENT.CAPTURE.COMPLETED') { + const checkoutId = event.resource?.custom_id; + if (checkoutId) { + const { rows } = await pool.query(`SELECT status FROM checkouts WHERE id = $1`, [checkoutId]); + if (rows.length && rows[0].status !== 'completed') { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await completeCheckout(client, parseInt(checkoutId, 10), 'paypal', event.resource?.id, event); + await client.query('COMMIT'); + } catch (e) { + await client.query('ROLLBACK'); + console.error('webhook completeCheckout failed', e); + } finally { + client.release(); + } + } + } + } + res.status(200).end(); + } catch (err) { + console.error('webhook error', err); + res.status(500).end(); + } +}); + +export { router, webhookRouter }; diff --git a/backend/src/routes/shippingAddresses.ts b/backend/src/routes/shippingAddresses.ts new file mode 100644 index 0000000..441c08e --- /dev/null +++ b/backend/src/routes/shippingAddresses.ts @@ -0,0 +1,104 @@ +import { Router, Request, Response } from 'express'; +import { pool } from '../db'; +import { requireCustomer } from '../middleware/customerAuth'; +import { validateAddress, uspsConfigured } from '../usps'; + +const router = Router(); + +router.get('/', requireCustomer, async (req: Request, res: Response) => { + const { rows } = await pool.query( + `SELECT * FROM shipping_addresses WHERE customer_id = $1 ORDER BY is_default DESC, created_at DESC`, + [req.customerId] + ); + res.json(rows); +}); + +router.post('/', requireCustomer, async (req: Request, res: Response) => { + const { fullName, addressLine1, addressLine2, city, state, postalCode, country, isDefault } = req.body; + if (!fullName || !addressLine1 || !city || !state || !postalCode) { + return res.status(400).json({ error: 'fullName, addressLine1, city, state, and postalCode are required' }); + } + + let uspsResult = { validated: false, deliverable: null as boolean | null, standardized: null as Record | null, reason: undefined as string | undefined }; + if ((country || 'US') === 'US') { + uspsResult = await validateAddress({ addressLine1, addressLine2, city, state, postalCode }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + if (isDefault) { + await client.query(`UPDATE shipping_addresses SET is_default = false WHERE customer_id = $1`, [req.customerId]); + } + const { rows } = await client.query( + `INSERT INTO shipping_addresses + (customer_id, full_name, address_line1, address_line2, city, state, postal_code, country, is_default, usps_validated, usps_standardized) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`, + [ + req.customerId, fullName, addressLine1, addressLine2 || null, city, state, postalCode, + country || 'US', !!isDefault, uspsResult.deliverable === true, uspsResult.standardized + ] + ); + await client.query('COMMIT'); + res.status(201).json({ address: rows[0], uspsCheck: uspsResult, uspsConfigured: uspsConfigured() }); + } catch (err) { + await client.query('ROLLBACK'); + console.error(err); + res.status(500).json({ error: 'internal error' }); + } finally { + client.release(); + } +}); + +router.put('/:id', requireCustomer, async (req: Request, res: Response) => { + const { fullName, addressLine1, addressLine2, city, state, postalCode, country, isDefault } = req.body; + const client = await pool.connect(); + try { + await client.query('BEGIN'); + if (isDefault) { + await client.query(`UPDATE shipping_addresses SET is_default = false WHERE customer_id = $1`, [req.customerId]); + } + const { rows } = await client.query( + `UPDATE shipping_addresses + SET full_name=$1, address_line1=$2, address_line2=$3, city=$4, state=$5, postal_code=$6, country=$7, is_default=$8, + usps_validated = false, usps_standardized = NULL + WHERE id=$9 AND customer_id=$10 RETURNING *`, + [fullName, addressLine1, addressLine2 || null, city, state, postalCode, country || 'US', !!isDefault, req.params.id, req.customerId] + ); + if (!rows.length) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'not found' }); } + await client.query('COMMIT'); + res.json(rows[0]); + } catch (err) { + await client.query('ROLLBACK'); + console.error(err); + res.status(500).json({ error: 'internal error' }); + } finally { + client.release(); + } +}); + +router.delete('/:id', requireCustomer, async (req: Request, res: Response) => { + await pool.query(`DELETE FROM shipping_addresses WHERE id = $1 AND customer_id = $2`, [req.params.id, req.customerId]); + res.status(204).end(); +}); + +router.post('/:id/set-default', requireCustomer, async (req: Request, res: Response) => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query(`UPDATE shipping_addresses SET is_default = false WHERE customer_id = $1`, [req.customerId]); + const { rows } = await client.query( + `UPDATE shipping_addresses SET is_default = true WHERE id = $1 AND customer_id = $2 RETURNING *`, + [req.params.id, req.customerId] + ); + await client.query('COMMIT'); + res.json(rows[0]); + } catch (err) { + await client.query('ROLLBACK'); + res.status(500).json({ error: 'internal error' }); + } finally { + client.release(); + } +}); + +export default router; diff --git a/backend/src/server.ts b/backend/src/server.ts index faa2c5e..4b6b2df 100755 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,16 +1,59 @@ +import cron from 'node-cron'; import app from './app'; import { pool } from './db'; +import { sendMail } from './mailer'; +// Release cart holds whose expiry has passed, every 5 minutes. setInterval(async () => { try { - await pool.query( - `UPDATE items SET status = 'available', reserved_until = NULL, paypal_order_id = NULL - WHERE status = 'reserved' AND reserved_until < now()` + const { rows } = await pool.query( + `DELETE FROM cart_items WHERE expires_at < now() RETURNING item_id` ); + for (const row of rows) { + await pool.query(`UPDATE items SET status = 'available' WHERE id = $1 AND status = 'reserved'`, [row.item_id]); + } } catch (err) { - console.error('reservation sweep failed:', (err as Error).message); + console.error('cart expiry sweep failed:', (err as Error).message); } -}, 60 * 1000); +}, 5 * 60 * 1000); + +// Daily cart reminder emails at 9am server time, for customers who opted into marketing email. +cron.schedule('0 9 * * *', async () => { + try { + const { rows } = await pool.query(` + SELECT c.email, c.name, i.name AS item_name, ci.expires_at, ci.id AS cart_item_id + FROM cart_items ci + JOIN carts ca ON ca.id = ci.cart_id + JOIN customers c ON c.id = ca.customer_id + JOIN items i ON i.id = ci.item_id + WHERE c.marketing_consent = true + AND (ci.last_reminder_sent_at IS NULL OR ci.last_reminder_sent_at < now() - interval '20 hours') + AND ci.expires_at > now() + `); + + const byEmail = new Map(); + for (const row of rows) { + if (!byEmail.has(row.email)) byEmail.set(row.email, { name: row.name, items: [] }); + byEmail.get(row.email)!.items.push({ name: row.item_name, expiresAt: row.expires_at, cartItemId: row.cart_item_id }); + } + + for (const [email, data] of byEmail) { + const itemList = data.items.map(i => `
  • ${i.name} — reserved until ${i.expiresAt.toLocaleString()}
  • `).join(''); + await sendMail( + email, + 'Items waiting in your cart', + `

    Hi${data.name ? ' ' + data.name : ''},

    +

    You still have items in your cart at Redefined Designs:

    +
      ${itemList}
    +

    View your cart before your reservation expires.

    ` + ); + const ids = data.items.map(i => i.cartItemId); + await pool.query(`UPDATE cart_items SET last_reminder_sent_at = now() WHERE id = ANY($1::int[])`, [ids]); + } + } catch (err) { + console.error('daily cart reminder job failed:', (err as Error).message); + } +}); const PORT = parseInt(process.env.PORT || '3000', 10); app.listen(PORT, () => console.log(`redefined-designs listening on ${PORT}`)); diff --git a/backend/src/usps.ts b/backend/src/usps.ts new file mode 100644 index 0000000..63e2664 --- /dev/null +++ b/backend/src/usps.ts @@ -0,0 +1,82 @@ +interface UspsToken { + accessToken: string; + expiresAt: number; +} + +let cachedToken: UspsToken | null = null; + +const USPS_BASE = + process.env.USPS_ENV === 'production' + ? 'https://apis.usps.com' + : 'https://apis-tem.usps.com'; + +export function uspsConfigured(): boolean { + return !!(process.env.USPS_CLIENT_ID && process.env.USPS_CLIENT_SECRET); +} + +async function getToken(): Promise { + if (cachedToken && cachedToken.expiresAt > Date.now() + 60_000) { + return cachedToken.accessToken; + } + const resp = await fetch(`${USPS_BASE}/oauth2/v3/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_id: process.env.USPS_CLIENT_ID, + client_secret: process.env.USPS_CLIENT_SECRET, + grant_type: 'client_credentials', + scope: 'addresses' + }) + }); + const data = await resp.json(); + if (!resp.ok) throw new Error('USPS auth failed: ' + JSON.stringify(data)); + cachedToken = { + accessToken: data.access_token, + expiresAt: Date.now() + (parseInt(data.expires_in, 10) || 3000) * 1000 + }; + return cachedToken.accessToken; +} + +export interface AddressInput { + addressLine1: string; + addressLine2?: string | null; + city: string; + state: string; + postalCode: string; +} + +export interface UspsValidationResult { + validated: boolean; + deliverable: boolean | null; + standardized: Record | null; + reason?: string; +} + +// Only validates domestic US addresses — USPS Addresses API has no international coverage. +export async function validateAddress(address: AddressInput): Promise { + if (!uspsConfigured()) { + return { validated: false, deliverable: null, standardized: null, reason: 'USPS not configured' }; + } + try { + const token = await getToken(); + const params = new URLSearchParams({ + streetAddress: address.addressLine1, + city: address.city, + state: address.state, + ZIPCode: address.postalCode + }); + if (address.addressLine2) params.set('secondaryAddress', address.addressLine2); + + const resp = await fetch(`${USPS_BASE}/addresses/v3/address?${params.toString()}`, { + headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } + }); + const data = await resp.json(); + if (!resp.ok) { + return { validated: false, deliverable: null, standardized: null, reason: data.error?.message || 'USPS lookup failed' }; + } + const deliverable = data.additionalInfo?.DPVConfirmation === 'Y'; + return { validated: true, deliverable, standardized: data }; + } catch (err) { + return { validated: false, deliverable: null, standardized: null, reason: (err as Error).message }; + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 106cd63..d550dbc 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,29 +1,28 @@ import { useEffect, useState, useCallback } from 'react'; -import { Layout, Typography, Switch, Row, Col, Spin, Button, theme } from 'antd'; +import { Layout, Typography, Switch, Row, Col, Spin, Button, theme, Badge } from 'antd'; +import { ShoppingCartOutlined } from '@ant-design/icons'; import { Link } from 'react-router-dom'; -import { Item, SiteConfig, fetchItems, fetchConfig } from './api'; +import { Item, fetchItems } from './api'; import ItemCard from './components/ItemCard'; import { useThemeMode } from './theme/ThemeContext'; import { useCustomerAuth } from './customer/CustomerAuthContext'; +import { useCart } from './cart/CartContext'; const { Header, Content, Footer } = Layout; const { Title } = Typography; export default function App() { const [items, setItems] = useState([]); - const [config, setConfig] = useState(null); const { mode, toggle } = useThemeMode(); const { customer } = useCustomerAuth(); + const { items: cartItems } = useCart(); const { token } = theme.useToken(); const load = useCallback(() => { fetchItems().then(setItems); }, []); - useEffect(() => { - load(); - fetchConfig().then(setConfig); - }, [load]); + useEffect(() => { load(); }, [load]); return ( @@ -39,6 +38,11 @@ export default function App() {
    + + + ) : ( @@ -50,11 +54,11 @@ export default function App() {
    - {!config ? : ( + {!items.length ? : ( {items.map(item => ( - + ))} @@ -65,4 +69,4 @@ export default function App() {
    ); -} \ No newline at end of file +} diff --git a/frontend/src/admin/Admin.tsx b/frontend/src/admin/Admin.tsx index 80df6c2..d6ea2a7 100755 --- a/frontend/src/admin/Admin.tsx +++ b/frontend/src/admin/Admin.tsx @@ -11,6 +11,7 @@ import '@uiw/react-markdown-preview/markdown.css'; import { Item, fetchAdminItems, saveItem, deleteItem, deleteItemImage, markSold, markAvailable } from '../api'; import { useThemeMode } from '../theme/ThemeContext'; import Customers from './Customers'; +import Settings from './Settings'; const { Header, Content } = Layout; const { Title } = Typography; @@ -37,10 +38,7 @@ function Inventory() { function openEdit(item: Item) { setEditingItem(item); - form.setFieldsValue({ - name: item.name, - price: item.price_cents / 100 - }); + form.setFieldsValue({ name: item.name, price: item.price_cents / 100 }); setFileList([]); setDescription(item.description || ''); setModalOpen(true); @@ -52,9 +50,7 @@ function Inventory() { fd.append('name', values.name); fd.append('description', description); fd.append('price', String(values.price)); - fileList.forEach(f => { - if (f.originFileObj) fd.append('images', f.originFileObj as File); - }); + fileList.forEach(f => { if (f.originFileObj) fd.append('images', f.originFileObj as File); }); await saveItem(editingItem?.id ?? null, fd); message.success(editingItem ? 'Item updated' : 'Item added'); setModalOpen(false); @@ -85,26 +81,18 @@ function Inventory() { {images.length > 1 && ( - - +{images.length - 1} - + +{images.length - 1} )} ) : null }, { title: 'Name', dataIndex: 'name' }, - { - title: 'Price', - dataIndex: 'price_cents', - render: (v: number) => `$${(v / 100).toFixed(2)}` - }, + { title: 'Price', dataIndex: 'price_cents', render: (v: number) => `$${(v / 100).toFixed(2)}` }, { title: 'Status', dataIndex: 'status', render: (status: string) => ( - - {status.toUpperCase()} - + {status.toUpperCase()} ) }, { @@ -129,61 +117,34 @@ function Inventory() { - setModalOpen(false)} - destroyOnClose - width={720} - > + setModalOpen(false)} destroyOnClose width={720}>
    - - setDescription(val || '')} - height={220} - preview="live" - /> + setDescription(val || '')} height={220} preview="live" /> - - {editingItem && editingItem.images.length > 0 && ( {editingItem.images.map(img => (
    -
    ))}
    )} - - false} - onChange={({ fileList }) => setFileList(fileList.slice(-6))} - maxCount={6} - multiple - listType="picture-card" - > + false} onChange={({ fileList }) => setFileList(fileList.slice(-6))} + maxCount={6} multiple listType="picture-card">
    Upload
    @@ -200,16 +161,8 @@ export default function Admin() { return ( -
    - - Admin - +
    + Admin
    @@ -219,10 +172,11 @@ export default function Admin() { defaultActiveKey="inventory" items={[ { key: 'inventory', label: 'Inventory', children: }, - { key: 'customers', label: 'Customers', children: } + { key: 'customers', label: 'Customers', children: }, + { key: 'settings', label: 'Settings', children: } ]} /> ); -} \ No newline at end of file +} diff --git a/frontend/src/admin/Settings.tsx b/frontend/src/admin/Settings.tsx new file mode 100644 index 0000000..ad308b5 --- /dev/null +++ b/frontend/src/admin/Settings.tsx @@ -0,0 +1,38 @@ +import { useEffect, useState } from 'react'; +import { Form, InputNumber, Button, Typography, message, Card } from 'antd'; +import { fetchAdminSettings, updateAdminSettings } from './adminSettingsApi'; + +const { Title, Text } = Typography; + +export default function Settings() { + const [form] = Form.useForm(); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchAdminSettings().then(s => { + form.setFieldsValue({ cartExpiryHours: s.cartExpiryHours }); + setLoading(false); + }); + }, [form]); + + async function handleSave() { + const values = await form.validateFields(); + await updateAdminSettings(values); + message.success('Settings saved'); + } + + return ( + + Cart Settings + + How long an item stays reserved in a customer's cart before it's automatically released back to available inventory. + + + + + + + + + ); +} diff --git a/frontend/src/admin/adminSettingsApi.ts b/frontend/src/admin/adminSettingsApi.ts new file mode 100644 index 0000000..fb5e528 --- /dev/null +++ b/frontend/src/admin/adminSettingsApi.ts @@ -0,0 +1,17 @@ +export interface AdminSettings { + cartExpiryHours: number; +} + +export async function fetchAdminSettings(): Promise { + const res = await fetch('/api/admin/settings'); + return res.json(); +} + +export async function updateAdminSettings(settings: AdminSettings): Promise { + const res = await fetch('/api/admin/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings) + }); + return res.json(); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index d6e2e8b..2cc39cc 100755 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,15 +1,9 @@ -export interface ItemImage { - id: number; - image_path: string; - sort_order: number; -} - export interface Item { id: number; name: string; description: string | null; price_cents: number; - images: ItemImage[]; + images: { id: number; image_path: string; sort_order: number }[]; status: 'available' | 'reserved' | 'sold'; } @@ -57,27 +51,3 @@ export async function markAvailable(id: number): Promise { const res = await fetch(`/api/admin/items/${id}/mark-available`, { method: 'POST' }); return res.json(); } - -export async function createPaypalOrder(itemId: number): Promise { - const res = await fetch(`/api/checkout/paypal/${itemId}/create`, { method: 'POST' }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error || 'Could not start checkout'); - return data.orderID; -} - -export async function capturePaypalOrder(itemId: number, orderID: string): Promise { - const res = await fetch(`/api/checkout/paypal/${itemId}/capture`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ orderID }) - }); - if (!res.ok) throw new Error('Payment captured but confirmation failed — contact the seller.'); -} - -export async function demoPurchase(itemId: number): Promise { - const res = await fetch(`/api/checkout/demo/${itemId}/purchase`, { method: 'POST' }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data.error || 'Demo purchase failed'); - } -} diff --git a/frontend/src/cart/Cart.tsx b/frontend/src/cart/Cart.tsx new file mode 100644 index 0000000..59ffecb --- /dev/null +++ b/frontend/src/cart/Cart.tsx @@ -0,0 +1,237 @@ +import { useEffect, useState } from 'react'; +import { + Layout, Typography, List, Button, Empty, Card, Radio, Form, Input, + Checkbox, Modal, message, Tag, Spin, theme +} from 'antd'; +import { useNavigate } from 'react-router-dom'; +import { + CartItem, ShippingAddress, fetchCart, removeFromCart, fetchAddresses, + createAddress, createCartPaypalOrder, captureCartPaypalOrder, demoCartPurchase +} from './cartApi'; +import { fetchConfig, SiteConfig } from '../api'; +import { loadPaypalSdk } from '../paypal'; +import { useCart } from './CartContext'; +import { useCustomerAuth } from '../customer/CustomerAuthContext'; + +const { Header, Content } = Layout; +const { Title, Text } = Typography; + +function timeRemaining(expiresAt: string): string { + const diffMs = new Date(expiresAt).getTime() - Date.now(); + if (diffMs <= 0) return 'expiring…'; + const hours = Math.floor(diffMs / (1000 * 60 * 60)); + const mins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); + return `${hours}h ${mins}m left`; +} + +export default function Cart() { + const [items, setItems] = useState([]); + const [addresses, setAddresses] = useState([]); + const [selectedAddressId, setSelectedAddressId] = useState(null); + const [addAddressOpen, setAddAddressOpen] = useState(false); + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [checkingOut, setCheckingOut] = useState(false); + const [form] = Form.useForm(); + const { refresh: refreshCartContext } = useCart(); + const { customer, loading: authLoading } = useCustomerAuth(); + const navigate = useNavigate(); + const { token } = theme.useToken(); + + useEffect(() => { + if (!authLoading && !customer) navigate('/login'); + }, [authLoading, customer, navigate]); + + function loadAll() { + setLoading(true); + Promise.all([fetchCart(), fetchAddresses(), fetchConfig()]).then(([cartData, addrs, cfg]) => { + setItems(cartData.items); + setAddresses(addrs); + const def = addrs.find(a => a.is_default); + setSelectedAddressId(def ? def.id : (addrs[0]?.id ?? null)); + setConfig(cfg); + setLoading(false); + }); + } + + useEffect(() => { if (customer) loadAll(); }, [customer]); + + const total = items.reduce((sum, i) => sum + i.price_cents, 0); + + async function handleRemove(itemId: number) { + await removeFromCart(itemId); + message.success('Removed from cart'); + refreshCartContext(); + loadAll(); + } + + async function handleAddAddress() { + const values = await form.validateFields(); + const result = await createAddress(values); + if (result.uspsConfigured && !result.uspsCheck.deliverable) { + Modal.warning({ + title: 'Address could not be verified', + content: result.uspsCheck.reason || 'USPS could not confirm this address is deliverable. It has been saved, but double-check it before checkout.' + }); + } + message.success('Address saved'); + setAddAddressOpen(false); + form.resetFields(); + loadAll(); + } + + async function handleDemoCheckout() { + if (!selectedAddressId) { message.error('Select a shipping address first'); return; } + setCheckingOut(true); + try { + await demoCartPurchase(selectedAddressId); + message.success('Order complete!'); + refreshCartContext(); + loadAll(); + } catch (err) { + message.error((err as Error).message); + } finally { + setCheckingOut(false); + } + } + + const [paypalReady, setPaypalReady] = useState(false); + useEffect(() => { + if (config?.paypalClientId) { + loadPaypalSdk(config.paypalClientId, config.currency).then(() => setPaypalReady(true)).catch(() => {}); + } + }, [config]); + + useEffect(() => { + if (!paypalReady || !selectedAddressId || items.length === 0) return; + const container = document.getElementById('paypal-cart-buttons'); + if (!container || !(window as any).paypal) return; + container.innerHTML = ''; + (window as any).paypal.Buttons({ + createOrder: async () => { + const { orderID } = await createCartPaypalOrder(selectedAddressId); + return orderID; + }, + onApprove: async (data: { orderID: string }) => { + try { + await captureCartPaypalOrder(data.orderID); + message.success('Order complete!'); + refreshCartContext(); + loadAll(); + } catch (err) { + message.error((err as Error).message); + } + }, + onError: (err: unknown) => { + console.error(err); + message.error('Checkout error, please try again.'); + } + }).render('#paypal-cart-buttons'); + }, [paypalReady, selectedAddressId, items.length]); + + if (authLoading || loading) return ; + + return ( + +
    + Your Cart +
    + + {items.length === 0 ? ( + + ) : ( + <> + ( + handleRemove(item.item_id)}>Remove]}> + } + title={item.name} + description={ + + {timeRemaining(item.expires_at)} + + } + /> +
    ${(item.price_cents / 100).toFixed(2)}
    +
    + )} + /> + Total: ${(total / 100).toFixed(2)} + + + {addresses.length === 0 ? ( + No saved addresses yet. + ) : ( + setSelectedAddressId(e.target.value)} + style={{ display: 'flex', flexDirection: 'column', gap: 8 }} + > + {addresses.map(a => ( + + {a.full_name}, {a.address_line1}{a.address_line2 ? `, ${a.address_line2}` : ''}, {a.city}, {a.state} {a.postal_code}{' '} + {a.usps_validated + ? USPS Verified + : Not Verified} + + ))} + + )} + + + + + {!selectedAddressId && Select a shipping address to check out.} + {config?.paypalClientId && selectedAddressId &&
    } + {config?.demoMode && selectedAddressId && ( + + )} + + + )} + + + setAddAddressOpen(false)} + destroyOnClose + > +
    + + + + + + + + + + + + + + + + + + + + Make this my default address + + +
    + + ); +} diff --git a/frontend/src/cart/CartContext.tsx b/frontend/src/cart/CartContext.tsx new file mode 100644 index 0000000..b9ea92d --- /dev/null +++ b/frontend/src/cart/CartContext.tsx @@ -0,0 +1,35 @@ +import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'; +import { CartItem, fetchCart } from './cartApi'; +import { useCustomerAuth } from '../customer/CustomerAuthContext'; + +interface CartContextValue { + items: CartItem[]; + itemIds: Set; + refresh: () => void; +} + +const CartContext = createContext({ items: [], itemIds: new Set(), refresh: () => {} }); + +export function useCart() { + return useContext(CartContext); +} + +export function CartProvider({ children }: { children: React.ReactNode }) { + const [items, setItems] = useState([]); + const { customer } = useCustomerAuth(); + + const refresh = useCallback(() => { + if (!customer) { setItems([]); return; } + fetchCart().then(data => setItems(data.items)).catch(() => setItems([])); + }, [customer]); + + useEffect(() => { refresh(); }, [refresh]); + + const itemIds = new Set(items.map(i => i.item_id)); + + return ( + + {children} + + ); +} diff --git a/frontend/src/cart/cartApi.ts b/frontend/src/cart/cartApi.ts new file mode 100644 index 0000000..7d0a0eb --- /dev/null +++ b/frontend/src/cart/cartApi.ts @@ -0,0 +1,97 @@ +export interface CartItem { + item_id: number; + name: string; + price_cents: number; + status: string; + added_at: string; + expires_at: string; + images: { id: number; image_path: string }[]; +} + +export interface ShippingAddress { + id: number; + full_name: string; + address_line1: string; + address_line2: string | null; + city: string; + state: string; + postal_code: string; + country: string; + is_default: boolean; + usps_validated: boolean; +} + +async function handle(res: Response): Promise { + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || 'Request failed'); + } + return res.json(); +} + +export function fetchCart(): Promise<{ items: CartItem[] }> { + return fetch('/api/cart').then(res => handle(res)); +} + +export function addToCart(itemId: number): Promise<{ itemId: number; expiresAt: string }> { + return fetch(`/api/cart/items/${itemId}`, { method: 'POST' }).then(res => handle(res)); +} + +export function removeFromCart(itemId: number): Promise { + return fetch(`/api/cart/items/${itemId}`, { method: 'DELETE' }).then(() => undefined); +} + +export function fetchAddresses(): Promise { + return fetch('/api/customers/me/addresses').then(res => handle(res)); +} + +export interface AddressInput { + fullName: string; + addressLine1: string; + addressLine2?: string; + city: string; + state: string; + postalCode: string; + country?: string; + isDefault?: boolean; +} + +export function createAddress(input: AddressInput): Promise<{ address: ShippingAddress; uspsCheck: { validated: boolean; deliverable: boolean | null; reason?: string }; uspsConfigured: boolean }> { + return fetch('/api/customers/me/addresses', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input) + }).then(res => handle(res)); +} + +export function deleteAddress(id: number): Promise { + return fetch(`/api/customers/me/addresses/${id}`, { method: 'DELETE' }).then(() => undefined); +} + +export function setDefaultAddress(id: number): Promise { + return fetch(`/api/customers/me/addresses/${id}/set-default`, { method: 'POST' }).then(res => handle(res)); +} + +export function createCartPaypalOrder(shippingAddressId: number): Promise<{ orderID: string }> { + return fetch('/api/checkout/cart/paypal/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ shippingAddressId }) + }).then(res => handle(res)); +} + +export function captureCartPaypalOrder(orderID: string): Promise { + return fetch('/api/checkout/cart/paypal/capture', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ orderID }) + }).then(res => handle(res)); +} + +export function demoCartPurchase(shippingAddressId: number): Promise { + return fetch('/api/checkout/cart/demo/purchase', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ shippingAddressId }) + }).then(res => handle(res)); +} diff --git a/frontend/src/components/ItemCard.tsx b/frontend/src/components/ItemCard.tsx index 15fc0af..31095b6 100755 --- a/frontend/src/components/ItemCard.tsx +++ b/frontend/src/components/ItemCard.tsx @@ -1,66 +1,52 @@ -import { useEffect, useRef, useState } from 'react'; +import { useState, useRef } from 'react'; import { Card, Badge, Typography, Carousel, Button, message } from 'antd'; import { LeftOutlined, RightOutlined } from '@ant-design/icons'; import type { CarouselRef } from 'antd/es/carousel'; -import { Item, SiteConfig, createPaypalOrder, capturePaypalOrder, demoPurchase } from '../api'; -import { loadPaypalSdk } from '../paypal'; +import { Item } from '../api'; import MarkdownView from './MarkdownView'; +import { addToCart } from '../cart/cartApi'; +import { useCart } from '../cart/CartContext'; +import { useCustomerAuth } from '../customer/CustomerAuthContext'; +import AuthPromptModal from '../customer/AuthPromptModal'; const { Text, Title } = Typography; -declare global { - interface Window { paypal?: any; } -} - interface Props { item: Item; - config: SiteConfig; - onPurchased: () => void; + onChanged: () => void; } -export default function ItemCard({ item, config, onPurchased }: Props) { - const slotRef = useRef(null); +export default function ItemCard({ item, onChanged }: Props) { const carouselRef = useRef(null); - const [paypalReady, setPaypalReady] = useState(false); + const [authModalOpen, setAuthModalOpen] = useState(false); + const [adding, setAdding] = useState(false); + const { customer } = useCustomerAuth(); + const { itemIds, refresh: refreshCart } = useCart(); - useEffect(() => { - if (item.status !== 'available' || !config.paypalClientId) return; - loadPaypalSdk(config.paypalClientId, config.currency) - .then(() => setPaypalReady(true)) - .catch(err => console.error(err)); - }, [item.status, config.paypalClientId, config.currency]); + const inMyCart = itemIds.has(item.id); - useEffect(() => { - if (!paypalReady || !window.paypal || !slotRef.current) return; - slotRef.current.innerHTML = ''; - window.paypal.Buttons({ - createOrder: () => createPaypalOrder(item.id), - onApprove: async (data: { orderID: string }) => { - try { - await capturePaypalOrder(item.id, data.orderID); - message.success('Purchase complete — thank you!'); - onPurchased(); - } catch (err) { - message.error((err as Error).message); - } - }, - onError: (err: unknown) => { - console.error(err); - message.error('Checkout error, please try again.'); - } - }).render(slotRef.current); - }, [paypalReady, item.id]); - - async function handleDemoBuy() { + async function doAddToCart() { + setAdding(true); try { - await demoPurchase(item.id); - message.success('Demo purchase complete — item marked sold.'); - onPurchased(); + await addToCart(item.id); + message.success('Added to cart'); + refreshCart(); + onChanged(); } catch (err) { message.error((err as Error).message); + } finally { + setAdding(false); } } + function handleAddClick() { + if (!customer) { + setAuthModalOpen(true); + return; + } + doAddToCart(); + } + const hasMultiple = item.images.length > 1; const cover = item.images.length ? ( @@ -74,20 +60,10 @@ export default function ItemCard({ item, config, onPurchased }: Props) { {hasMultiple && ( <> - + ); + } else if (item.status === 'reserved') { + actionButton = inMyCart + ? + : ; + } + const card = ( {item.name}
    ${(item.price_cents / 100).toFixed(2)}
    - {item.status === 'available' && ( - <> - {config.paypalClientId &&
    } - {config.demoMode && ( - - )} - - )} + {actionButton} + setAuthModalOpen(false)} + onSuccess={() => { setAuthModalOpen(false); doAddToCart(); }} + /> ); diff --git a/frontend/src/customer/AuthPromptModal.tsx b/frontend/src/customer/AuthPromptModal.tsx new file mode 100644 index 0000000..54cffe7 --- /dev/null +++ b/frontend/src/customer/AuthPromptModal.tsx @@ -0,0 +1,100 @@ +import { useState } from 'react'; +import { Modal, Form, Input, Button, Checkbox, Tabs, Alert } from 'antd'; +import { registerCustomer, loginCustomer } from './customerApi'; +import { useCustomerAuth } from './CustomerAuthContext'; + +interface Props { + open: boolean; + onClose: () => void; + onSuccess: () => void; +} + +export default function AuthPromptModal({ open, onClose, onSuccess }: Props) { + const [tab, setTab] = useState<'register' | 'login'>('register'); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [form] = Form.useForm(); + const { refresh } = useCustomerAuth(); + + async function handleRegister(values: any) { + setLoading(true); + setError(null); + try { + await registerCustomer(values.email, values.password, values.name, !!values.marketingConsent); + refresh(); + onSuccess(); + } catch (err) { + setError((err as Error).message); + } finally { + setLoading(false); + } + } + + async function handleLogin(values: any) { + setLoading(true); + setError(null); + try { + await loginCustomer(values.email, values.password); + refresh(); + onSuccess(); + } catch (err) { + setError((err as Error).message); + } finally { + setLoading(false); + } + } + + return ( + + {error && } + setTab(k as 'register' | 'login')} + items={[ + { + key: 'register', + label: 'Create Account', + children: ( +
    + + + + + + + + + + + Send me occasional emails about new one-of-a-kind items. + + + + ) + }, + { + key: 'login', + label: 'Log In', + children: ( +
    + + + + + + + + + ) + } + ]} + /> +
    + ); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index d624ea7..886c7e0 100755 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -9,7 +9,9 @@ import Login from './customer/Login'; import Register from './customer/Register'; import Account from './customer/Account'; import PrivacyPolicy from './customer/PrivacyPolicy'; +import Cart from './cart/Cart'; import { CustomerAuthProvider } from './customer/CustomerAuthContext'; +import { CartProvider } from './cart/CartContext'; import { ThemeModeProvider, useThemeMode } from './theme/ThemeContext'; import './styles.css'; @@ -29,6 +31,7 @@ function Root() { } /> } /> } /> + } /> } /> @@ -40,7 +43,9 @@ ReactDOM.createRoot(document.getElementById('root')!).render( - + + +