Merge pull request 'feat: add customer cart with expiry, shipping addresses with USPS validation, and multi-item PayPal checkout' (#9) from feature/cart-shipping-checkout into main
Reviewed-on: #9
This commit was merged in pull request #9.
This commit is contained in:
+9
-7
@@ -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));
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
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;
|
||||
@@ -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<number> {
|
||||
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;
|
||||
@@ -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<string> {
|
||||
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 };
|
||||
@@ -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<string, unknown> | 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;
|
||||
+48
-5
@@ -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<string, { name: string | null; items: { name: string; expiresAt: Date; cartItemId: number }[] }>();
|
||||
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 => `<li>${i.name} — reserved until ${i.expiresAt.toLocaleString()}</li>`).join('');
|
||||
await sendMail(
|
||||
email,
|
||||
'Items waiting in your cart',
|
||||
`<p>Hi${data.name ? ' ' + data.name : ''},</p>
|
||||
<p>You still have items in your cart at Redefined Designs:</p>
|
||||
<ul>${itemList}</ul>
|
||||
<p><a href="${process.env.PUBLIC_URL}/cart">View your cart</a> before your reservation expires.</p>`
|
||||
);
|
||||
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}`));
|
||||
|
||||
@@ -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<string> {
|
||||
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<string, unknown> | null;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
// Only validates domestic US addresses — USPS Addresses API has no international coverage.
|
||||
export async function validateAddress(address: AddressInput): Promise<UspsValidationResult> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
+13
-9
@@ -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<Item[]>([]);
|
||||
const [config, setConfig] = useState<SiteConfig | null>(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 (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
@@ -39,6 +38,11 @@ export default function App() {
|
||||
</Title>
|
||||
<div className="site-header-actions">
|
||||
<Switch checked={mode === 'dark'} onChange={toggle} checkedChildren="Dark" unCheckedChildren="Light" />
|
||||
<Link to="/cart">
|
||||
<Badge count={cartItems.length} size="small">
|
||||
<Button icon={<ShoppingCartOutlined />} />
|
||||
</Badge>
|
||||
</Link>
|
||||
{customer ? (
|
||||
<Link to="/account"><Button>My Account</Button></Link>
|
||||
) : (
|
||||
@@ -50,11 +54,11 @@ export default function App() {
|
||||
</div>
|
||||
</Header>
|
||||
<Content style={{ padding: 24 }}>
|
||||
{!config ? <Spin /> : (
|
||||
{!items.length ? <Spin /> : (
|
||||
<Row gutter={[20, 20]}>
|
||||
{items.map(item => (
|
||||
<Col key={item.id} xs={24} sm={12} md={8} lg={6}>
|
||||
<ItemCard item={item} config={config} onPurchased={load} />
|
||||
<ItemCard item={item} onChanged={load} />
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
@@ -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() {
|
||||
<span style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<img src={images[0].image_path} style={{ width: 60 }} />
|
||||
{images.length > 1 && (
|
||||
<Tag style={{ position: 'absolute', bottom: -4, right: -8, fontSize: 10 }}>
|
||||
+{images.length - 1}
|
||||
</Tag>
|
||||
<Tag style={{ position: 'absolute', bottom: -4, right: -8, fontSize: 10 }}>+{images.length - 1}</Tag>
|
||||
)}
|
||||
</span>
|
||||
) : 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) => (
|
||||
<Tag color={status === 'sold' ? 'red' : status === 'reserved' ? 'orange' : 'green'}>
|
||||
{status.toUpperCase()}
|
||||
</Tag>
|
||||
<Tag color={status === 'sold' ? 'red' : status === 'reserved' ? 'orange' : 'green'}>{status.toUpperCase()}</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
@@ -129,61 +117,34 @@ function Inventory() {
|
||||
</div>
|
||||
<Table rowKey="id" dataSource={items} columns={columns} scroll={{ x: true }} />
|
||||
|
||||
<Modal
|
||||
title={editingItem ? 'Edit Item' : 'Add Item'}
|
||||
open={modalOpen}
|
||||
onOk={handleOk}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
destroyOnClose
|
||||
width={720}
|
||||
>
|
||||
<Modal title={editingItem ? 'Edit Item' : 'Add Item'} open={modalOpen} onOk={handleOk} onCancel={() => setModalOpen(false)} destroyOnClose width={720}>
|
||||
<div data-color-mode={mode}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="Name" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Description (Markdown supported)">
|
||||
<MDEditor
|
||||
value={description}
|
||||
onChange={(val) => setDescription(val || '')}
|
||||
height={220}
|
||||
preview="live"
|
||||
/>
|
||||
<MDEditor value={description} onChange={(val) => setDescription(val || '')} height={220} preview="live" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="price" label="Price (USD)" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
{editingItem && editingItem.images.length > 0 && (
|
||||
<Form.Item label="Existing Images (front / back / etc.)">
|
||||
<Space wrap>
|
||||
{editingItem.images.map(img => (
|
||||
<div key={img.id} style={{ position: 'relative' }}>
|
||||
<AntImage src={img.image_path} width={80} height={80} style={{ objectFit: 'cover' }} />
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
style={{ position: 'absolute', top: 0, right: 0 }}
|
||||
onClick={() => handleDeleteImage(editingItem.id, img.id)}
|
||||
/>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} style={{ position: 'absolute', top: 0, right: 0 }}
|
||||
onClick={() => handleDeleteImage(editingItem.id, img.id)} />
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item label={editingItem ? 'Add More Images' : 'Images (front, back, etc.)'}>
|
||||
<Upload
|
||||
fileList={fileList}
|
||||
beforeUpload={() => false}
|
||||
onChange={({ fileList }) => setFileList(fileList.slice(-6))}
|
||||
maxCount={6}
|
||||
multiple
|
||||
listType="picture-card"
|
||||
>
|
||||
<Upload fileList={fileList} beforeUpload={() => false} onChange={({ fileList }) => setFileList(fileList.slice(-6))}
|
||||
maxCount={6} multiple listType="picture-card">
|
||||
<div><UploadOutlined /><div style={{ marginTop: 8 }}>Upload</div></div>
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
@@ -200,16 +161,8 @@ export default function Admin() {
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Header
|
||||
className="site-header"
|
||||
style={{
|
||||
background: token.colorBgContainer,
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`
|
||||
}}
|
||||
>
|
||||
<Title level={3} className="site-header-title" style={{ color: token.colorText }}>
|
||||
Admin
|
||||
</Title>
|
||||
<Header className="site-header" style={{ background: token.colorBgContainer, borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
|
||||
<Title level={3} className="site-header-title" style={{ color: token.colorText }}>Admin</Title>
|
||||
<div className="site-header-actions">
|
||||
<Switch checked={mode === 'dark'} onChange={toggle} checkedChildren="Dark" unCheckedChildren="Light" />
|
||||
</div>
|
||||
@@ -219,7 +172,8 @@ export default function Admin() {
|
||||
defaultActiveKey="inventory"
|
||||
items={[
|
||||
{ key: 'inventory', label: 'Inventory', children: <Inventory /> },
|
||||
{ key: 'customers', label: 'Customers', children: <Customers /> }
|
||||
{ key: 'customers', label: 'Customers', children: <Customers /> },
|
||||
{ key: 'settings', label: 'Settings', children: <Settings /> }
|
||||
]}
|
||||
/>
|
||||
</Content>
|
||||
|
||||
@@ -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 (
|
||||
<Card style={{ maxWidth: 480 }}>
|
||||
<Title level={4}>Cart Settings</Title>
|
||||
<Text type="secondary">
|
||||
How long an item stays reserved in a customer's cart before it's automatically released back to available inventory.
|
||||
</Text>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }} disabled={loading}>
|
||||
<Form.Item name="cartExpiryHours" label="Cart expiry (hours)" rules={[{ required: true }]}>
|
||||
<InputNumber min={0.5} step={0.5} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Button type="primary" onClick={handleSave} loading={loading}>Save</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface AdminSettings {
|
||||
cartExpiryHours: number;
|
||||
}
|
||||
|
||||
export async function fetchAdminSettings(): Promise<AdminSettings> {
|
||||
const res = await fetch('/api/admin/settings');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateAdminSettings(settings: AdminSettings): Promise<AdminSettings> {
|
||||
const res = await fetch('/api/admin/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
+1
-31
@@ -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<Item> {
|
||||
const res = await fetch(`/api/admin/items/${id}/mark-available`, { method: 'POST' });
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createPaypalOrder(itemId: number): Promise<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<CartItem[]>([]);
|
||||
const [addresses, setAddresses] = useState<ShippingAddress[]>([]);
|
||||
const [selectedAddressId, setSelectedAddressId] = useState<number | null>(null);
|
||||
const [addAddressOpen, setAddAddressOpen] = useState(false);
|
||||
const [config, setConfig] = useState<SiteConfig | null>(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 <Spin style={{ margin: 48 }} />;
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Header style={{ background: token.colorBgContainer, borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
|
||||
<Title level={3} style={{ color: token.colorText, margin: 0, lineHeight: '64px' }}>Your Cart</Title>
|
||||
</Header>
|
||||
<Content style={{ padding: 24, maxWidth: 700, margin: '0 auto', width: '100%' }}>
|
||||
{items.length === 0 ? (
|
||||
<Empty description="Your cart is empty" />
|
||||
) : (
|
||||
<>
|
||||
<List
|
||||
dataSource={items}
|
||||
renderItem={item => (
|
||||
<List.Item actions={[<Button danger size="small" onClick={() => handleRemove(item.item_id)}>Remove</Button>]}>
|
||||
<List.Item.Meta
|
||||
avatar={item.images[0] && <img src={item.images[0].image_path} style={{ width: 60, height: 60, objectFit: 'cover' }} />}
|
||||
title={item.name}
|
||||
description={
|
||||
<Text type={new Date(item.expires_at).getTime() - Date.now() < 60 * 60 * 1000 ? 'danger' : 'secondary'}>
|
||||
{timeRemaining(item.expires_at)}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<div>${(item.price_cents / 100).toFixed(2)}</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
<Title level={4} style={{ textAlign: 'right', marginTop: 16 }}>Total: ${(total / 100).toFixed(2)}</Title>
|
||||
|
||||
<Card title="Shipping Address" style={{ marginTop: 24 }}>
|
||||
{addresses.length === 0 ? (
|
||||
<Text type="secondary">No saved addresses yet.</Text>
|
||||
) : (
|
||||
<Radio.Group
|
||||
value={selectedAddressId}
|
||||
onChange={(e) => setSelectedAddressId(e.target.value)}
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: 8 }}
|
||||
>
|
||||
{addresses.map(a => (
|
||||
<Radio key={a.id} value={a.id}>
|
||||
{a.full_name}, {a.address_line1}{a.address_line2 ? `, ${a.address_line2}` : ''}, {a.city}, {a.state} {a.postal_code}{' '}
|
||||
{a.usps_validated
|
||||
? <Tag color="green">USPS Verified</Tag>
|
||||
: <Tag color="default">Not Verified</Tag>}
|
||||
</Radio>
|
||||
))}
|
||||
</Radio.Group>
|
||||
)}
|
||||
<Button style={{ marginTop: 12 }} onClick={() => setAddAddressOpen(true)}>Add New Address</Button>
|
||||
</Card>
|
||||
|
||||
<Card title="Checkout" style={{ marginTop: 24 }}>
|
||||
{!selectedAddressId && <Text type="warning">Select a shipping address to check out.</Text>}
|
||||
{config?.paypalClientId && selectedAddressId && <div id="paypal-cart-buttons" />}
|
||||
{config?.demoMode && selectedAddressId && (
|
||||
<Button
|
||||
block
|
||||
type={config.paypalClientId ? 'default' : 'primary'}
|
||||
style={{ marginTop: 8 }}
|
||||
loading={checkingOut}
|
||||
onClick={handleDemoCheckout}
|
||||
>
|
||||
{config.paypalClientId ? 'Checkout (Demo)' : 'Checkout'}
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</Content>
|
||||
|
||||
<Modal
|
||||
title="Add Shipping Address"
|
||||
open={addAddressOpen}
|
||||
onOk={handleAddAddress}
|
||||
onCancel={() => setAddAddressOpen(false)}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="fullName" label="Full Name" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="addressLine1" label="Address Line 1" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="addressLine2" label="Address Line 2">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="city" label="City" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="state" label="State" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="postalCode" label="ZIP Code" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="isDefault" valuePropName="checked" initialValue={addresses.length === 0}>
|
||||
<Checkbox>Make this my default address</Checkbox>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -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<number>;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
const CartContext = createContext<CartContextValue>({ items: [], itemIds: new Set(), refresh: () => {} });
|
||||
|
||||
export function useCart() {
|
||||
return useContext(CartContext);
|
||||
}
|
||||
|
||||
export function CartProvider({ children }: { children: React.ReactNode }) {
|
||||
const [items, setItems] = useState<CartItem[]>([]);
|
||||
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 (
|
||||
<CartContext.Provider value={{ items, itemIds, refresh }}>
|
||||
{children}
|
||||
</CartContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -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<T>(res: Response): Promise<T> {
|
||||
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<void> {
|
||||
return fetch(`/api/cart/items/${itemId}`, { method: 'DELETE' }).then(() => undefined);
|
||||
}
|
||||
|
||||
export function fetchAddresses(): Promise<ShippingAddress[]> {
|
||||
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<void> {
|
||||
return fetch(`/api/customers/me/addresses/${id}`, { method: 'DELETE' }).then(() => undefined);
|
||||
}
|
||||
|
||||
export function setDefaultAddress(id: number): Promise<ShippingAddress> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
return fetch('/api/checkout/cart/demo/purchase', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ shippingAddressId })
|
||||
}).then(res => handle(res));
|
||||
}
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
export default function ItemCard({ item, onChanged }: Props) {
|
||||
const carouselRef = useRef<CarouselRef>(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) {
|
||||
</Carousel>
|
||||
{hasMultiple && (
|
||||
<>
|
||||
<Button
|
||||
className="carousel-arrow carousel-arrow-left"
|
||||
shape="circle"
|
||||
size="small"
|
||||
icon={<LeftOutlined />}
|
||||
onClick={(e) => { e.stopPropagation(); carouselRef.current?.prev(); }}
|
||||
/>
|
||||
<Button
|
||||
className="carousel-arrow carousel-arrow-right"
|
||||
shape="circle"
|
||||
size="small"
|
||||
icon={<RightOutlined />}
|
||||
onClick={(e) => { e.stopPropagation(); carouselRef.current?.next(); }}
|
||||
/>
|
||||
<Button className="carousel-arrow carousel-arrow-left" shape="circle" size="small" icon={<LeftOutlined />}
|
||||
onClick={(e) => { e.stopPropagation(); carouselRef.current?.prev(); }} />
|
||||
<Button className="carousel-arrow carousel-arrow-right" shape="circle" size="small" icon={<RightOutlined />}
|
||||
onClick={(e) => { e.stopPropagation(); carouselRef.current?.next(); }} />
|
||||
<div className="carousel-count">{item.images.length} photos</div>
|
||||
</>
|
||||
)}
|
||||
@@ -96,26 +72,30 @@ export default function ItemCard({ item, config, onPurchased }: Props) {
|
||||
<div className="card-cover-placeholder" />
|
||||
);
|
||||
|
||||
let actionButton = null;
|
||||
if (item.status === 'available') {
|
||||
actionButton = (
|
||||
<Button block type="primary" loading={adding} onClick={handleAddClick} style={{ marginTop: 8 }}>
|
||||
Add to Cart
|
||||
</Button>
|
||||
);
|
||||
} else if (item.status === 'reserved') {
|
||||
actionButton = inMyCart
|
||||
? <Button block disabled style={{ marginTop: 8 }}>In Your Cart</Button>
|
||||
: <Button block disabled style={{ marginTop: 8 }}>Reserved</Button>;
|
||||
}
|
||||
|
||||
const card = (
|
||||
<Card hoverable cover={cover} className="item-card">
|
||||
<Title level={5} style={{ marginBottom: 4 }}>{item.name}</Title>
|
||||
<MarkdownView content={item.description} />
|
||||
<div className="price">${(item.price_cents / 100).toFixed(2)}</div>
|
||||
{item.status === 'available' && (
|
||||
<>
|
||||
{config.paypalClientId && <div className="paypal-slot" ref={slotRef} />}
|
||||
{config.demoMode && (
|
||||
<Button
|
||||
block
|
||||
type={config.paypalClientId ? 'default' : 'primary'}
|
||||
onClick={handleDemoBuy}
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
{config.paypalClientId ? 'Buy Now (Demo)' : 'Buy Now'}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{actionButton}
|
||||
<AuthPromptModal
|
||||
open={authModalOpen}
|
||||
onClose={() => setAuthModalOpen(false)}
|
||||
onSuccess={() => { setAuthModalOpen(false); doAddToCart(); }}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
|
||||
|
||||
@@ -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<string | null>(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 (
|
||||
<Modal
|
||||
title="Create an account to continue"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
>
|
||||
{error && <Alert type="error" message={error} style={{ marginBottom: 16 }} />}
|
||||
<Tabs
|
||||
activeKey={tab}
|
||||
onChange={(k) => setTab(k as 'register' | 'login')}
|
||||
items={[
|
||||
{
|
||||
key: 'register',
|
||||
label: 'Create Account',
|
||||
children: (
|
||||
<Form form={form} layout="vertical" onFinish={handleRegister}>
|
||||
<Form.Item name="name" label="Name">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="email" label="Email" rules={[{ required: true, type: 'email' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label="Password" rules={[{ required: true, min: 8, message: 'At least 8 characters' }]}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
<Form.Item name="marketingConsent" valuePropName="checked" initialValue={false}>
|
||||
<Checkbox>Send me occasional emails about new one-of-a-kind items.</Checkbox>
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" block loading={loading}>Create account & continue</Button>
|
||||
</Form>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'login',
|
||||
label: 'Log In',
|
||||
children: (
|
||||
<Form layout="vertical" onFinish={handleLogin}>
|
||||
<Form.Item name="email" label="Email" rules={[{ required: true, type: 'email' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label="Password" rules={[{ required: true }]}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" block loading={loading}>Log in & continue</Button>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/account" element={<Account />} />
|
||||
<Route path="/cart" element={<Cart />} />
|
||||
<Route path="/privacy" element={<PrivacyPolicy />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
@@ -40,7 +43,9 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeModeProvider>
|
||||
<CustomerAuthProvider>
|
||||
<Root />
|
||||
<CartProvider>
|
||||
<Root />
|
||||
</CartProvider>
|
||||
</CustomerAuthProvider>
|
||||
</ThemeModeProvider>
|
||||
</React.StrictMode>
|
||||
|
||||
Reference in New Issue
Block a user