feat: favorite items and notify when a favorite is sold (#34) #53

Merged
bermudalamb merged 1 commits from feature/favorites into main 2026-08-18 13:51:20 -05:00
15 changed files with 760 additions and 11 deletions
Showing only changes of commit f626f27e75 - Show all commits
@@ -0,0 +1,34 @@
exports.up = (pgm) => {
pgm.sql(`
CREATE TABLE IF NOT EXISTS favorites (
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (customer_id, item_id)
);
-- The primary key covers lookups by customer. Notifying everyone who
-- favorited an item that just sold goes the other way.
CREATE INDEX IF NOT EXISTS favorites_item_id_idx ON favorites (item_id);
-- A consent of its own rather than reusing marketing_consent. Being told
-- that a specific item you asked about has gone is a narrower thing than
-- agreeing to marketing, and folding one into the other would make the
-- existing consent record no longer describe what was agreed to.
--
-- Mirrors the marketing consent columns: the flag, when it was given, and
-- the exact wording shown at the time.
ALTER TABLE customers ADD COLUMN IF NOT EXISTS favorite_alerts BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE customers ADD COLUMN IF NOT EXISTS favorite_alerts_at TIMESTAMPTZ;
ALTER TABLE customers ADD COLUMN IF NOT EXISTS favorite_alerts_text TEXT;
`);
};
exports.down = (pgm) => {
pgm.sql(`
ALTER TABLE customers DROP COLUMN IF EXISTS favorite_alerts_text;
ALTER TABLE customers DROP COLUMN IF EXISTS favorite_alerts_at;
ALTER TABLE customers DROP COLUMN IF EXISTS favorite_alerts;
DROP TABLE IF EXISTS favorites;
`);
};
+50
View File
@@ -0,0 +1,50 @@
import { pool } from './db';
import { sendMail } from './mailer';
// Shown to the customer when they opt in, and stored verbatim against their
// consent so the record says what they actually agreed to — the same pattern
// the marketing consent already uses.
export const FAVORITE_ALERTS_CONSENT_TEXT =
'Email me when an item I have favorited is sold to someone else, so I know it is no longer available.';
interface Recipient {
email: string;
item_name: string;
}
// Called *after* the sale has been committed, never inside the transaction.
// Emailing about a sale that then rolled back would be worse than a late
// notification, and the transaction should not be held open for SMTP.
//
// `buyerId` is excluded: telling customers the item they just bought is no
// longer available reads as a bug.
export async function notifyFavoritersOfSale(itemIds: number[], buyerId: number | null): Promise<void> {
if (!itemIds.length) return;
const { rows } = await pool.query<Recipient>(
`SELECT c.email, i.name AS item_name
FROM favorites f
JOIN customers c ON c.id = f.customer_id
JOIN items i ON i.id = f.item_id
WHERE f.item_id = ANY($1::int[])
AND c.favorite_alerts = true
AND c.disabled_at IS NULL
AND ($2::int IS NULL OR c.id <> $2::int)`,
[itemIds, buyerId]
);
for (const recipient of rows) {
// Sent one at a time and independently: one failed address must not stop
// the rest, and the sale itself has already succeeded regardless.
sendMail(
recipient.email,
`"${recipient.item_name}" has been sold`,
`<p>An item you favorited has been sold to another customer, so it is no longer available.</p>
<p><b>${recipient.item_name}</b></p>
<p>Every piece is one of a kind, so this one will not be restocked. You can browse what is
still available at <a href="${process.env.PUBLIC_URL}">Redefined Designs</a>.</p>
<p>You are receiving this because you asked to be told when a favorited item is sold. You can
turn these off on your account page.</p>`
).catch(err => console.error('favorite sold notification failed', err));
}
}
+4
View File
@@ -8,6 +8,7 @@ import { ADMIN_ITEM_SELECT } from '../itemSelect';
import { asyncRoute } from '../asyncRoute'; import { asyncRoute } from '../asyncRoute';
import { parseItemFilters, buildItemFilterSql, FilterError } from '../itemFilters'; import { parseItemFilters, buildItemFilterSql, FilterError } from '../itemFilters';
import { tagColorFor } from '../utils'; import { tagColorFor } from '../utils';
import { notifyFavoritersOfSale } from '../favoriteAlerts';
const router = Router(); const router = Router();
@@ -243,6 +244,9 @@ router.post('/items/:id/mark-sold', async (req: Request, res: Response) => {
`UPDATE items SET status='sold', sold_at=now() WHERE id=$1 RETURNING *`, `UPDATE items SET status='sold', sold_at=now() WHERE id=$1 RETURNING *`,
[req.params.id] [req.params.id]
); );
// No buyer to exclude: an admin marking an item sold has no associated
// customer, so everyone watching it hears about it.
await notifyFavoritersOfSale([Number(req.params.id)], null);
res.json(rows[0]); res.json(rows[0]);
}); });
+16 -4
View File
@@ -1,6 +1,7 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import { pool } from '../db'; import { pool } from '../db';
import { requireCustomer } from '../middleware/customerAuth'; import { requireCustomer } from '../middleware/customerAuth';
import { notifyFavoritersOfSale } from '../favoriteAlerts';
const router = Router(); const router = Router();
const webhookRouter = Router(); const webhookRouter = Router();
@@ -140,7 +141,10 @@ router.post('/paypal/create', requireCustomer, async (req: Request, res: Respons
} }
}); });
async function completeCheckout(client: any, checkoutId: number, processor: string, processorOrderId: string | null, rawEvent: unknown) { // Returns the sold item ids and the buyer, so the caller can notify favoriters
// *after* COMMIT. Sending inside the transaction would email people about a
// sale that then rolled back, and would hold the transaction open for SMTP.
async function completeCheckout(client: any, checkoutId: number, processor: string, processorOrderId: string | null, rawEvent: unknown): Promise<{ itemIds: number[]; buyerId: number | null }> {
const { rows: checkoutItems } = await client.query( const { rows: checkoutItems } = await client.query(
`SELECT item_id, price_cents FROM checkout_items WHERE checkout_id = $1`, `SELECT item_id, price_cents FROM checkout_items WHERE checkout_id = $1`,
[checkoutId] [checkoutId]
@@ -158,6 +162,11 @@ async function completeCheckout(client: any, checkoutId: number, processor: stri
await client.query(`DELETE FROM cart_items WHERE item_id = $1`, [ci.item_id]); 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]); await client.query(`UPDATE checkouts SET status = 'completed', raw_event = $1 WHERE id = $2`, [rawEvent, checkoutId]);
return {
itemIds: checkoutItems.map((ci: { item_id: number }) => ci.item_id),
buyerId: customerId ?? null
};
} }
router.post('/paypal/capture', requireCustomer, async (req: Request, res: Response) => { router.post('/paypal/capture', requireCustomer, async (req: Request, res: Response) => {
@@ -181,8 +190,9 @@ router.post('/paypal/capture', requireCustomer, async (req: Request, res: Respon
if (!rows.length) return res.status(404).json({ error: 'checkout not found' }); if (!rows.length) return res.status(404).json({ error: 'checkout not found' });
await client.query('BEGIN'); await client.query('BEGIN');
await completeCheckout(client, rows[0].id, 'paypal', orderID, capture); const sold = await completeCheckout(client, rows[0].id, 'paypal', orderID, capture);
await client.query('COMMIT'); await client.query('COMMIT');
await notifyFavoritersOfSale(sold.itemIds, sold.buyerId);
res.json({ status: 'completed' }); res.json({ status: 'completed' });
} catch (err) { } catch (err) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
@@ -204,8 +214,9 @@ router.post('/demo/purchase', requireCustomer, async (req: Request, res: Respons
const opened = await openCheckout(client, req.customerId as number, shippingAddressId, 'demo', `demo-${Date.now()}`); const opened = await openCheckout(client, req.customerId as number, shippingAddressId, 'demo', `demo-${Date.now()}`);
if (!opened.ok) { await client.query('ROLLBACK'); return res.status(400).json({ error: opened.error }); } if (!opened.ok) { await client.query('ROLLBACK'); return res.status(400).json({ error: opened.error }); }
await completeCheckout(client, opened.checkoutId, 'demo', null, { demo: true }); const sold = await completeCheckout(client, opened.checkoutId, 'demo', null, { demo: true });
await client.query('COMMIT'); await client.query('COMMIT');
await notifyFavoritersOfSale(sold.itemIds, sold.buyerId);
res.json({ status: 'completed' }); res.json({ status: 'completed' });
} catch (err) { } catch (err) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
@@ -244,8 +255,9 @@ webhookRouter.post('/', async (req: Request, res: Response) => {
const client = await pool.connect(); const client = await pool.connect();
try { try {
await client.query('BEGIN'); await client.query('BEGIN');
await completeCheckout(client, parseInt(checkoutId, 10), 'paypal', event.resource?.id, event); const sold = await completeCheckout(client, parseInt(checkoutId, 10), 'paypal', event.resource?.id, event);
await client.query('COMMIT'); await client.query('COMMIT');
await notifyFavoritersOfSale(sold.itemIds, sold.buyerId);
} catch (e) { } catch (e) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
console.error('webhook completeCheckout failed', e); console.error('webhook completeCheckout failed', e);
+47
View File
@@ -5,6 +5,7 @@ import { pool } from '../db';
import { requireCustomer } from '../middleware/customerAuth'; import { requireCustomer } from '../middleware/customerAuth';
import { sendMail } from '../mailer'; import { sendMail } from '../mailer';
import { MARKETING_CONSENT_TEXT, isValidEmail } from '../utils'; import { MARKETING_CONSENT_TEXT, isValidEmail } from '../utils';
import { FAVORITE_ALERTS_CONSENT_TEXT } from '../favoriteAlerts';
import { asyncRoute } from '../asyncRoute'; import { asyncRoute } from '../asyncRoute';
import { passwordResetRequestLimiter } from '../rateLimit'; import { passwordResetRequestLimiter } from '../rateLimit';
@@ -38,6 +39,7 @@ function publicCustomer(c: any) {
name: c.name, name: c.name,
email_verified: c.email_verified, email_verified: c.email_verified,
marketing_consent: c.marketing_consent, marketing_consent: c.marketing_consent,
favorite_alerts: c.favorite_alerts,
created_at: c.created_at created_at: c.created_at
}; };
} }
@@ -218,6 +220,51 @@ router.post('/logout', async (req: Request, res: Response) => {
res.status(204).end(); res.status(204).end();
}); });
router.get('/me/favorites', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
const { rows } = await pool.query(
`SELECT f.item_id, f.created_at, i.name, i.status
FROM favorites f JOIN items i ON i.id = f.item_id
WHERE f.customer_id = $1
ORDER BY f.created_at DESC`,
[req.customerId]
);
res.json(rows);
}));
router.post('/me/favorites/:itemId', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
const { rows: item } = await pool.query(`SELECT id FROM items WHERE id = $1`, [req.params.itemId]);
if (!item.length) return res.status(404).json({ error: 'not found' });
// Idempotent: a double click, or two tabs, must not be an error.
await pool.query(
`INSERT INTO favorites (customer_id, item_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[req.customerId, req.params.itemId]
);
res.status(201).json({ item_id: Number(req.params.itemId) });
}));
router.delete('/me/favorites/:itemId', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
await pool.query(`DELETE FROM favorites WHERE customer_id = $1 AND item_id = $2`,
[req.customerId, req.params.itemId]);
res.status(204).end();
}));
// A consent of its own, deliberately not the marketing flag. Recorded the same
// way as the marketing consent — flag, timestamp, and the exact wording shown —
// so the record says what was actually agreed to.
router.put('/me/favorite-alerts', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
const enabled = !!req.body?.enabled;
const { rows } = await pool.query(
`UPDATE customers
SET favorite_alerts = $1,
favorite_alerts_at = $2,
favorite_alerts_text = $3
WHERE id = $4 RETURNING *`,
[enabled, enabled ? new Date() : null, enabled ? FAVORITE_ALERTS_CONSENT_TEXT : null, req.customerId]
);
res.json(publicCustomer(rows[0]));
}));
router.get('/me', requireCustomer, async (req: Request, res: Response) => { router.get('/me', requireCustomer, async (req: Request, res: Response) => {
const { rows } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [req.customerId]); const { rows } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
if (!rows.length) return res.status(404).json({ error: 'not found' }); if (!rows.length) return res.status(404).json({ error: 'not found' });
@@ -0,0 +1,242 @@
import request from 'supertest';
import app from '../../src/app';
import { pool } from '../../src/db';
import { resetDb, closeDb } from './setup/testDb';
jest.mock('../../src/mailer', () => ({
sendMail: jest.fn().mockResolvedValue(undefined)
}));
import { sendMail } from '../../src/mailer';
const sentMail = sendMail as jest.MockedFunction<typeof sendMail>;
beforeEach(async () => {
await resetDb();
sentMail.mockClear();
});
afterAll(async () => {
await pool.end();
await closeDb();
});
const PASSWORD = 'supersecret123';
async function register(email: string) {
const agent = request.agent(app);
expect((await agent.post('/api/customers/register').send({ email, password: PASSWORD })).status).toBe(200);
const { rows } = await pool.query(`SELECT id FROM customers WHERE email = $1`, [email]);
return { agent, id: rows[0].id as number };
}
async function createItem(name: string) {
const { rows } = await pool.query(
`INSERT INTO items (name, price_cents) VALUES ($1, 1000) RETURNING id`,
[name]
);
return rows[0].id as number;
}
// Recipients of the "your favorite sold" mail, by address.
function soldNotificationsTo(): string[] {
return sentMail.mock.calls
.filter(call => String(call[1]).includes('has been sold'))
.map(call => String(call[0]));
}
describe('favoriting items', () => {
it('records a favorite and lists it back', async () => {
const itemId = await createItem('Oak table');
const { agent } = await register('fav@example.com');
expect((await agent.post(`/api/customers/me/favorites/${itemId}`)).status).toBe(201);
const res = await agent.get('/api/customers/me/favorites');
expect(res.status).toBe(200);
expect(res.body.map((f: { item_id: number }) => f.item_id)).toEqual([itemId]);
});
it('is idempotent, so a double click does not error', async () => {
const itemId = await createItem('Oak table');
const { agent } = await register('twice@example.com');
expect((await agent.post(`/api/customers/me/favorites/${itemId}`)).status).toBe(201);
expect((await agent.post(`/api/customers/me/favorites/${itemId}`)).status).toBe(201);
const { rows } = await pool.query(`SELECT COUNT(*)::int AS n FROM favorites`);
expect(rows[0].n).toBe(1);
});
it('unfavorites', async () => {
const itemId = await createItem('Oak table');
const { agent } = await register('unfav@example.com');
await agent.post(`/api/customers/me/favorites/${itemId}`);
expect((await agent.delete(`/api/customers/me/favorites/${itemId}`)).status).toBe(204);
expect((await agent.get('/api/customers/me/favorites')).body).toEqual([]);
});
it('requires a signed-in customer', async () => {
const itemId = await createItem('Oak table');
expect((await request(app).post(`/api/customers/me/favorites/${itemId}`)).status).toBe(401);
expect((await request(app).get('/api/customers/me/favorites')).status).toBe(401);
});
it('refuses to favorite an item that does not exist', async () => {
const { agent } = await register('ghost@example.com');
expect((await agent.post('/api/customers/me/favorites/999999')).status).toBe(404);
});
it('keeps each customer\'s favorites separate', async () => {
const itemId = await createItem('Oak table');
const { agent: mine } = await register('mine@example.com');
const { agent: theirs } = await register('theirs@example.com');
await mine.post(`/api/customers/me/favorites/${itemId}`);
expect((await theirs.get('/api/customers/me/favorites')).body).toEqual([]);
});
});
describe('favorite alert consent', () => {
it('is off for a new customer', async () => {
const { agent } = await register('default@example.com');
const res = await agent.get('/api/customers/me');
expect(res.body.favorite_alerts).toBe(false);
});
it('records when it was given and the wording shown', async () => {
const { agent, id } = await register('optin@example.com');
expect((await agent.put('/api/customers/me/favorite-alerts').send({ enabled: true })).status).toBe(200);
const { rows } = await pool.query(
`SELECT favorite_alerts, favorite_alerts_at, favorite_alerts_text FROM customers WHERE id = $1`,
[id]
);
expect(rows[0].favorite_alerts).toBe(true);
expect(rows[0].favorite_alerts_at).toBeTruthy();
expect(rows[0].favorite_alerts_text).toMatch(/favorited/i);
});
it('can be turned back off', async () => {
const { agent, id } = await register('optout@example.com');
await agent.put('/api/customers/me/favorite-alerts').send({ enabled: true });
await agent.put('/api/customers/me/favorite-alerts').send({ enabled: false });
const { rows } = await pool.query(`SELECT favorite_alerts FROM customers WHERE id = $1`, [id]);
expect(rows[0].favorite_alerts).toBe(false);
});
it('is independent of the marketing consent', async () => {
const { agent, id } = await register('separate@example.com');
await agent.put('/api/customers/me/favorite-alerts').send({ enabled: true });
// Opting into item alerts must not quietly sign anyone up for marketing.
const { rows } = await pool.query(
`SELECT marketing_consent, favorite_alerts FROM customers WHERE id = $1`,
[id]
);
expect(rows[0].favorite_alerts).toBe(true);
expect(rows[0].marketing_consent).toBe(false);
});
});
describe('notifying when a favorited item sells', () => {
async function buyViaDemo(agent: ReturnType<typeof request.agent>, itemId: number) {
expect((await agent.post(`/api/cart/items/${itemId}`)).status).toBe(201);
// Demo checkout needs a shipping address like any other.
const address = await agent.post('/api/customers/me/addresses').send({
fullName: 'Test Buyer',
addressLine1: '1 Test Street',
city: 'Austin',
state: 'TX',
postalCode: '78701'
});
expect(address.status).toBeLessThan(400);
const purchase = await agent
.post('/api/checkout/cart/demo/purchase')
.send({ shippingAddressId: address.body.address.id });
expect(purchase.status).toBeLessThan(400);
}
it('emails a favoriter who opted in', async () => {
const itemId = await createItem('Wanted item');
const { agent: watcher } = await register('watcher@example.com');
await watcher.post(`/api/customers/me/favorites/${itemId}`);
await watcher.put('/api/customers/me/favorite-alerts').send({ enabled: true });
const { agent: buyer } = await register('buyer@example.com');
await buyViaDemo(buyer, itemId);
expect(soldNotificationsTo()).toEqual(['watcher@example.com']);
});
it('does not email a favoriter who never opted in', async () => {
const itemId = await createItem('Wanted item');
const { agent: watcher } = await register('silent@example.com');
await watcher.post(`/api/customers/me/favorites/${itemId}`);
const { agent: buyer } = await register('buyer2@example.com');
await buyViaDemo(buyer, itemId);
expect(soldNotificationsTo()).toEqual([]);
});
it('does not tell the buyer their own purchase is unavailable', async () => {
const itemId = await createItem('Self bought');
const { agent: buyer } = await register('selfbuy@example.com');
await buyer.post(`/api/customers/me/favorites/${itemId}`);
await buyer.put('/api/customers/me/favorite-alerts').send({ enabled: true });
await buyViaDemo(buyer, itemId);
expect(soldNotificationsTo()).toEqual([]);
});
it('emails every opted-in favoriter except the buyer', async () => {
const itemId = await createItem('Popular item');
for (const email of ['a@example.com', 'b@example.com']) {
const { agent } = await register(email);
await agent.post(`/api/customers/me/favorites/${itemId}`);
await agent.put('/api/customers/me/favorite-alerts').send({ enabled: true });
}
const { agent: buyer } = await register('c@example.com');
await buyer.post(`/api/customers/me/favorites/${itemId}`);
await buyer.put('/api/customers/me/favorite-alerts').send({ enabled: true });
await buyViaDemo(buyer, itemId);
expect(soldNotificationsTo().sort()).toEqual(['a@example.com', 'b@example.com']);
});
it('notifies when an admin marks the item sold', async () => {
const itemId = await createItem('Marked sold');
const { agent: watcher } = await register('marked@example.com');
await watcher.post(`/api/customers/me/favorites/${itemId}`);
await watcher.put('/api/customers/me/favorite-alerts').send({ enabled: true });
expect((await request(app).post(`/api/admin/items/${itemId}/mark-sold`)).status).toBe(200);
expect(soldNotificationsTo()).toEqual(['marked@example.com']);
});
it('does not notify a disabled customer', async () => {
const itemId = await createItem('Disabled watcher');
const { agent: watcher, id } = await register('disabled@example.com');
await watcher.post(`/api/customers/me/favorites/${itemId}`);
await watcher.put('/api/customers/me/favorite-alerts').send({ enabled: true });
await request(app).post(`/api/admin/customers/${id}/disable`).expect(204);
await request(app).post(`/api/admin/items/${itemId}/mark-sold`);
expect(soldNotificationsTo()).toEqual([]);
});
it('does not notify about an item nobody favorited', async () => {
const itemId = await createItem('Unloved');
await request(app).post(`/api/admin/items/${itemId}/mark-sold`);
expect(soldNotificationsTo()).toEqual([]);
});
});
+1 -1
View File
@@ -29,7 +29,7 @@ export async function migrate(): Promise<void> {
export async function resetDb(): Promise<void> { export async function resetDb(): Promise<void> {
await testPool.query(` await testPool.query(`
TRUNCATE TABLE orders, checkout_items, checkouts, shipping_addresses, cart_items, carts, TRUNCATE TABLE orders, checkout_items, checkouts, shipping_addresses, cart_items, carts,
customer_tokens, customer_sessions, customers, item_tags, item_images, items, customer_tokens, customer_sessions, favorites, customers, item_tags, item_images, items,
tags, categories tags, categories
RESTART IDENTITY CASCADE RESTART IDENTITY CASCADE
`); `);
+87 -4
View File
@@ -1,6 +1,6 @@
import { useState, useRef } from 'react'; import { useState, useRef } from 'react';
import { Card, Badge, Typography, Carousel, Button, message, Tag } from 'antd'; import { Card, Badge, Typography, Carousel, Button, message, Tag, Modal, Tooltip } from 'antd';
import { LeftOutlined, RightOutlined } from '@ant-design/icons'; import { LeftOutlined, RightOutlined, HeartOutlined, HeartFilled } from '@ant-design/icons';
import type { CarouselRef } from 'antd/es/carousel'; import type { CarouselRef } from 'antd/es/carousel';
import { Item } from '../api'; import { Item } from '../api';
import MarkdownView from './MarkdownView'; import MarkdownView from './MarkdownView';
@@ -8,6 +8,8 @@ import { addToCart } from '../cart/cartApi';
import { useCart } from '../cart/CartContext'; import { useCart } from '../cart/CartContext';
import { useCustomerAuth } from '../customer/CustomerAuthContext'; import { useCustomerAuth } from '../customer/CustomerAuthContext';
import AuthPromptModal from '../customer/AuthPromptModal'; import AuthPromptModal from '../customer/AuthPromptModal';
import { useFavorites } from '../customer/FavoritesContext';
import { addFavorite, removeFavorite, setFavoriteAlerts } from '../customer/favoritesApi';
const { Text, Title } = Typography; const { Text, Title } = Typography;
@@ -20,10 +22,16 @@ export default function ItemCard({ item, onChanged }: Props) {
const carouselRef = useRef<CarouselRef>(null); const carouselRef = useRef<CarouselRef>(null);
const [authModalOpen, setAuthModalOpen] = useState(false); const [authModalOpen, setAuthModalOpen] = useState(false);
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const { customer } = useCustomerAuth(); const [favoriting, setFavoriting] = useState(false);
// Which action to run once the auth modal succeeds — the modal is shared by
// Add to Cart and the heart.
const [pendingAction, setPendingAction] = useState<'cart' | 'favorite'>('cart');
const { customer, loading: authLoading, refresh: refreshCustomer } = useCustomerAuth();
const { itemIds, refresh: refreshCart } = useCart(); const { itemIds, refresh: refreshCart } = useCart();
const { itemIds: favoriteIds, refresh: refreshFavorites } = useFavorites();
const inMyCart = itemIds.has(item.id); const inMyCart = itemIds.has(item.id);
const isFavorite = favoriteIds.has(item.id);
async function doAddToCart() { async function doAddToCart() {
setAdding(true); setAdding(true);
@@ -40,13 +48,71 @@ export default function ItemCard({ item, onChanged }: Props) {
} }
function handleAddClick() { function handleAddClick() {
// Until the session has resolved, `customer` is null for a signed-in
// visitor too, and prompting them to sign in again would be wrong.
if (authLoading) return;
if (!customer) { if (!customer) {
setPendingAction('cart');
setAuthModalOpen(true); setAuthModalOpen(true);
return; return;
} }
doAddToCart(); doAddToCart();
} }
// Asked only once a customer has actually favorited something, so the reason
// for asking is concrete rather than an abstract marketing prompt. This is a
// consent of its own — accepting it does not sign anyone up for marketing.
function offerAlerts() {
Modal.confirm({
title: 'Want to know if this sells?',
content:
'Every piece is one of a kind, so a favorite can be bought by someone else at any time. ' +
'We can email you if that happens, so you are not left waiting on something that has gone. ' +
'This is only about items you favorite — it is separate from any marketing email.',
okText: 'Yes, email me',
cancelText: 'No thanks',
onOk: async () => {
try {
await setFavoriteAlerts(true);
refreshCustomer();
message.success('We will let you know');
} catch (err) {
message.error((err as Error).message);
}
}
});
}
async function doToggleFavorite() {
setFavoriting(true);
const wasFavorite = isFavorite;
try {
if (wasFavorite) {
await removeFavorite(item.id);
} else {
await addFavorite(item.id);
}
refreshFavorites();
} catch (err) {
message.error((err as Error).message);
return;
} finally {
setFavoriting(false);
}
if (!wasFavorite && customer && !customer.favorite_alerts) offerAlerts();
}
function handleFavoriteClick() {
if (authLoading) return;
if (!customer) {
setPendingAction('favorite');
setAuthModalOpen(true);
return;
}
doToggleFavorite();
}
const hasMultiple = item.images.length > 1; const hasMultiple = item.images.length > 1;
const cover = item.images.length ? ( const cover = item.images.length ? (
@@ -87,7 +153,20 @@ export default function ItemCard({ item, onChanged }: Props) {
const card = ( const card = (
<Card hoverable cover={cover} className="item-card"> <Card hoverable cover={cover} className="item-card">
<div className="item-card-title">
<Title level={5} style={{ marginBottom: 4 }}>{item.name}</Title> <Title level={5} style={{ marginBottom: 4 }}>{item.name}</Title>
<Tooltip title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}>
<Button
type="text"
shape="circle"
loading={favoriting || authLoading}
aria-pressed={isFavorite}
aria-label={isFavorite ? `Remove ${item.name} from favorites` : `Add ${item.name} to favorites`}
icon={isFavorite ? <HeartFilled style={{ color: '#c41d7f' }} /> : <HeartOutlined />}
onClick={handleFavoriteClick}
/>
</Tooltip>
</div>
{item.category_name && <Text type="secondary" className="item-category">{item.category_name}</Text>} {item.category_name && <Text type="secondary" className="item-category">{item.category_name}</Text>}
<MarkdownView content={item.description} /> <MarkdownView content={item.description} />
{item.tags.length > 0 && ( {item.tags.length > 0 && (
@@ -102,7 +181,11 @@ export default function ItemCard({ item, onChanged }: Props) {
<AuthPromptModal <AuthPromptModal
open={authModalOpen} open={authModalOpen}
onClose={() => setAuthModalOpen(false)} onClose={() => setAuthModalOpen(false)}
onSuccess={() => { setAuthModalOpen(false); doAddToCart(); }} onSuccess={() => {
setAuthModalOpen(false);
if (pendingAction === 'favorite') doToggleFavorite();
else doAddToCart();
}}
/> />
</Card> </Card>
); );
+21
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { Card, Typography, Switch, Button, Table, Modal, message, Space, Divider } from 'antd'; import { Card, Typography, Switch, Button, Table, Modal, message, Space, Divider } from 'antd';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { fetchMyOrders, OrderHistoryItem, updateConsent, exportMyData, deleteMyAccount } from './customerApi'; import { fetchMyOrders, OrderHistoryItem, updateConsent, exportMyData, deleteMyAccount } from './customerApi';
import { setFavoriteAlerts } from './favoritesApi';
import { useCustomerAuth } from './CustomerAuthContext'; import { useCustomerAuth } from './CustomerAuthContext';
const { Title, Text } = Typography; const { Title, Text } = Typography;
@@ -21,6 +22,17 @@ export default function Account() {
if (!customer) return null; if (!customer) return null;
async function handleFavoriteAlertsToggle(checked: boolean) {
try {
await setFavoriteAlerts(checked);
} catch (err) {
message.error((err as Error).message);
return;
}
refresh();
message.success(checked ? 'We will email you when a favorite sells' : 'Turned off');
}
async function handleConsentToggle(checked: boolean) { async function handleConsentToggle(checked: boolean) {
await updateConsent(checked); await updateConsent(checked);
message.success(checked ? 'Subscribed to emails' : 'Unsubscribed from emails'); message.success(checked ? 'Subscribed to emails' : 'Unsubscribed from emails');
@@ -70,6 +82,15 @@ export default function Account() {
<Text>Receive emails about new items</Text> <Text>Receive emails about new items</Text>
</Space> </Space>
{/* A separate consent from marketing above, and shown separately so a
customer can hold one without the other. */}
<div style={{ marginTop: 12 }}>
<Space align="center">
<Switch checked={customer.favorite_alerts} onChange={handleFavoriteAlertsToggle} />
<Text>Email me when an item I favorited is sold</Text>
</Space>
</div>
<Divider /> <Divider />
<Title level={5}>Order History</Title> <Title level={5}>Order History</Title>
<Table <Table
@@ -0,0 +1,49 @@
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
import { Favorite, fetchFavorites } from './favoritesApi';
import { useCustomerAuth } from './CustomerAuthContext';
interface FavoritesContextValue {
favorites: Favorite[];
itemIds: Set<number>;
refresh: () => void;
}
const FavoritesContext = createContext<FavoritesContextValue>({
favorites: [],
itemIds: new Set(),
refresh: () => {}
});
export function useFavorites() {
return useContext(FavoritesContext);
}
// Mirrors CartProvider: one fetch for the whole storefront rather than each
// card asking whether it is favorited, and it clears on sign-out so one
// customer's favorites never show to the next.
export function FavoritesProvider({ children }: { children: React.ReactNode }) {
const [favorites, setFavorites] = useState<Favorite[]>([]);
const { customer } = useCustomerAuth();
const refresh = useCallback(() => {
fetchFavorites()
.then(setFavorites)
.catch(() => setFavorites([]));
}, []);
useEffect(() => {
if (customer) {
refresh();
} else {
setFavorites([]);
}
}, [customer, refresh]);
const itemIds = new Set(favorites.map(f => f.item_id));
return (
<FavoritesContext.Provider value={{ favorites, itemIds, refresh }}>
{children}
</FavoritesContext.Provider>
);
}
+1
View File
@@ -4,6 +4,7 @@ export interface Customer {
name: string | null; name: string | null;
email_verified: boolean; email_verified: boolean;
marketing_consent: boolean; marketing_consent: boolean;
favorite_alerts: boolean;
created_at: string; created_at: string;
} }
+48
View File
@@ -0,0 +1,48 @@
import { Customer } from './customerApi';
export interface Favorite {
item_id: number;
name: string;
status: 'available' | 'reserved' | 'sold';
created_at: string;
}
async function expectOk(res: Response, action: string): Promise<Response> {
if (res.ok) return res;
const detail = await res.json().catch(() => ({}));
throw new Error(detail.error || action);
}
export async function fetchFavorites(): Promise<Favorite[]> {
const res = await fetch('/api/customers/me/favorites');
// A signed-out visitor legitimately has none; anything else is a real error.
if (res.status === 401) return [];
await expectOk(res, 'failed to load favorites');
return res.json();
}
export async function addFavorite(itemId: number): Promise<void> {
await expectOk(
await fetch(`/api/customers/me/favorites/${itemId}`, { method: 'POST' }),
'failed to save favorite'
);
}
export async function removeFavorite(itemId: number): Promise<void> {
await expectOk(
await fetch(`/api/customers/me/favorites/${itemId}`, { method: 'DELETE' }),
'failed to remove favorite'
);
}
export async function setFavoriteAlerts(enabled: boolean): Promise<Customer> {
const res = await expectOk(
await fetch('/api/customers/me/favorite-alerts', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled })
}),
'failed to update notification preference'
);
return res.json();
}
+3
View File
@@ -15,6 +15,7 @@ import ResetPassword from './customer/ResetPassword';
import Cart from './cart/Cart'; import Cart from './cart/Cart';
import { CustomerAuthProvider } from './customer/CustomerAuthContext'; import { CustomerAuthProvider } from './customer/CustomerAuthContext';
import { CartProvider } from './cart/CartContext'; import { CartProvider } from './cart/CartContext';
import { FavoritesProvider } from './customer/FavoritesContext';
import { ThemeModeProvider, useThemeMode } from './theme/ThemeContext'; import { ThemeModeProvider, useThemeMode } from './theme/ThemeContext';
import './styles.css'; import './styles.css';
@@ -87,7 +88,9 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
<ThemeModeProvider> <ThemeModeProvider>
<CustomerAuthProvider> <CustomerAuthProvider>
<CartProvider> <CartProvider>
<FavoritesProvider>
<Root /> <Root />
</FavoritesProvider>
</CartProvider> </CartProvider>
</CustomerAuthProvider> </CustomerAuthProvider>
</ThemeModeProvider> </ThemeModeProvider>
+13
View File
@@ -142,3 +142,16 @@ body { margin: 0; }
.admin-category-node .ant-space { .admin-category-node .ant-space {
margin-left: auto; margin-left: auto;
} }
/* Item card title row: name on the left, favorite toggle on the right. */
.item-card-title {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.item-card-title .ant-typography {
flex: 1;
min-width: 0;
}
+142
View File
@@ -0,0 +1,142 @@
import { test, expect, Page } from '@playwright/test';
const PASSWORD = 'supersecret123';
const RUN = `f${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
const ITEM = `Favoritable ${RUN}`;
const uniqueEmail = () => `fav-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}@example.com`;
test.beforeAll(async ({ playwright }) => {
const api = await playwright.request.newContext({ baseURL: 'http://localhost:5173' });
const res = await api.post('/api/admin/items', {
multipart: { name: ITEM, description: '', price: '60', category_id: '', tags: '[]' }
});
expect(res.ok()).toBeTruthy();
await api.dispose();
});
async function register(page: Page, email: string) {
await page.goto('/register');
await page.getByRole('textbox', { name: 'Email' }).fill(email);
await page.getByLabel('Password').fill(PASSWORD);
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page).toHaveURL(/\/account/);
}
// Going to the storefront remounts the app, so the session is briefly still
// resolving. The heart deliberately ignores clicks in that window rather than
// wrongly prompting a signed-in customer to sign in, so wait for the header to
// show the account link — which is exactly what a real customer sees settle.
async function gotoStorefrontSignedIn(page: Page) {
await page.goto('/');
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible();
}
// The storefront paginates as items accumulate, so find the card by name.
function heart(page: Page, itemName: string) {
return page.getByRole('button', { name: new RegExp(`(Add|Remove) ${itemName}`) });
}
test.describe('Favoriting items', () => {
test('a signed-out visitor is prompted to sign in, and the favorite completes', async ({ page }) => {
await page.goto('/');
await heart(page, ITEM).first().click();
// Same inline prompt Add to Cart already uses.
await expect(page.getByRole('dialog')).toBeVisible();
const email = uniqueEmail();
await page.getByRole('dialog').getByRole('textbox', { name: 'Email' }).fill(email);
await page.getByRole('dialog').getByLabel('Password').fill(PASSWORD);
await page.getByRole('dialog').getByRole('button', { name: 'Create account' }).click();
// The favorite the visitor originally asked for is applied on success.
await expect(page.getByRole('button', { name: `Remove ${ITEM} from favorites` })).toBeVisible();
});
test('offers the alert opt-in with a reason, and it is not the marketing consent', async ({ page }) => {
const email = uniqueEmail();
await register(page, email);
await gotoStorefrontSignedIn(page);
await heart(page, ITEM).first().click();
const prompt = page.getByRole('dialog');
await expect(prompt).toBeVisible();
await expect(prompt).toContainText('Want to know if this sells?');
// The reason for asking has to be given, not just the ask.
await expect(prompt).toContainText(/one of a kind/i);
await expect(prompt).toContainText(/separate from any marketing/i);
await prompt.getByRole('button', { name: 'Yes, email me' }).click();
await expect(page.getByText('We will let you know')).toBeVisible();
const me = await (await page.request.get('/api/customers/me')).json();
expect(me.favorite_alerts).toBe(true);
// Accepting item alerts must not sign anyone up for marketing.
expect(me.marketing_consent).toBe(false);
});
test('declining the opt-in still keeps the favorite', async ({ page }) => {
const email = uniqueEmail();
await register(page, email);
await gotoStorefrontSignedIn(page);
await heart(page, ITEM).first().click();
await page.getByRole('dialog').getByRole('button', { name: 'No thanks' }).click();
await expect(page.getByRole('button', { name: `Remove ${ITEM} from favorites` })).toBeVisible();
const favorites = await (await page.request.get('/api/customers/me/favorites')).json();
expect(favorites).toHaveLength(1);
const me = await (await page.request.get('/api/customers/me')).json();
expect(me.favorite_alerts).toBe(false);
});
test('unfavoriting removes it', async ({ page }) => {
const email = uniqueEmail();
await register(page, email);
await gotoStorefrontSignedIn(page);
await heart(page, ITEM).first().click();
await page.getByRole('dialog').getByRole('button', { name: 'No thanks' }).click();
await expect(page.getByRole('button', { name: `Remove ${ITEM} from favorites` })).toBeVisible();
await page.getByRole('button', { name: `Remove ${ITEM} from favorites` }).click();
await expect(page.getByRole('button', { name: `Add ${ITEM} to favorites` })).toBeVisible();
const favorites = await (await page.request.get('/api/customers/me/favorites')).json();
expect(favorites).toEqual([]);
});
test('favorites survive a reload', async ({ page }) => {
const email = uniqueEmail();
await register(page, email);
await gotoStorefrontSignedIn(page);
await heart(page, ITEM).first().click();
await page.getByRole('dialog').getByRole('button', { name: 'No thanks' }).click();
await expect(page.getByRole('button', { name: `Remove ${ITEM} from favorites` })).toBeVisible();
// Proves it was stored server-side rather than held in component state.
await page.reload();
await expect(page.getByRole('button', { name: `Remove ${ITEM} from favorites` })).toBeVisible();
});
test('the account page can turn the alerts off again', async ({ page }) => {
const email = uniqueEmail();
await register(page, email);
await page.request.put('/api/customers/me/favorite-alerts', { data: { enabled: true } });
await page.goto('/account');
const toggle = page.getByText('Email me when an item I favorited is sold');
await expect(toggle).toBeVisible();
await page.getByRole('switch').last().click();
await expect(page.getByText('Turned off')).toBeVisible();
const me = await (await page.request.get('/api/customers/me')).json();
expect(me.favorite_alerts).toBe(false);
});
});