From f626f27e75da35fe453488128fef79fa2d1aff8e Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Tue, 18 Aug 2026 13:29:11 -0500 Subject: [PATCH] feat: favorite items and notify when a favorite is sold (#34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Customers can favorite and unfavorite items from the storefront, opt in to being told when a favorite is sold to someone else, and manage that preference from their account page. The opt-in is a consent of its own rather than the existing marketing flag. 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 leave marketing_consent_text no longer describing what was actually agreed to. It is recorded the same way as the marketing consent — flag, timestamp, and the exact wording shown — and accepting it does not set marketing_consent. The prompt appears only after a customer has actually favorited something, so the reason for asking is concrete rather than an abstract marketing ask, and it says plainly that it is separate from marketing email. Declining keeps the favorite. Notifications fire when an item reaches sold, either through checkout or an admin marking it sold, and never to the buyer — telling someone the item they just bought is unavailable reads as a bug. Reserved is deliberately not a trigger: reservations expire and get released, so a "gone" email would often be about an item still for sale. Disabled accounts are excluded, per #33. completeCheckout now returns the sold item ids and the buyer so its three call sites can notify after COMMIT. Sending inside the transaction would email people about a sale that then rolled back, and would hold the transaction open for SMTP. Each message is sent independently so one bad address cannot stop the rest, and the sale has already succeeded regardless. Favoriting while signed out opens the existing inline register/login modal, exactly as Add to Cart does, and completes the favorite on success. Also fixes a latent bug in the same component: while the session was still resolving, `customer` is null for a signed-in visitor too, so clicking Add to Cart or the new heart in that window prompted them to sign in again. Both now ignore clicks until the session has resolved, and the control shows as loading meanwhile. Co-Authored-By: Claude Opus 5 (1M context) --- .../migrations/1787100000000_add-favorites.js | 34 +++ backend/src/favoriteAlerts.ts | 50 ++++ backend/src/routes/admin.ts | 4 + backend/src/routes/cartCheckout.ts | 20 +- backend/src/routes/customers.ts | 47 ++++ .../integration/favorites.integration.test.ts | 242 ++++++++++++++++++ backend/tests/integration/setup/testDb.ts | 2 +- frontend/src/components/ItemCard.tsx | 93 ++++++- frontend/src/customer/Account.tsx | 21 ++ frontend/src/customer/FavoritesContext.tsx | 49 ++++ frontend/src/customer/customerApi.ts | 1 + frontend/src/customer/favoritesApi.ts | 48 ++++ frontend/src/main.tsx | 5 +- frontend/src/styles.css | 13 + frontend/tests/e2e/favorites.spec.ts | 142 ++++++++++ 15 files changed, 760 insertions(+), 11 deletions(-) create mode 100644 backend/migrations/1787100000000_add-favorites.js create mode 100644 backend/src/favoriteAlerts.ts create mode 100644 backend/tests/integration/favorites.integration.test.ts create mode 100644 frontend/src/customer/FavoritesContext.tsx create mode 100644 frontend/src/customer/favoritesApi.ts create mode 100644 frontend/tests/e2e/favorites.spec.ts diff --git a/backend/migrations/1787100000000_add-favorites.js b/backend/migrations/1787100000000_add-favorites.js new file mode 100644 index 0000000..7301064 --- /dev/null +++ b/backend/migrations/1787100000000_add-favorites.js @@ -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; + `); +}; diff --git a/backend/src/favoriteAlerts.ts b/backend/src/favoriteAlerts.ts new file mode 100644 index 0000000..f252f12 --- /dev/null +++ b/backend/src/favoriteAlerts.ts @@ -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 { + if (!itemIds.length) return; + + const { rows } = await pool.query( + `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`, + `

An item you favorited has been sold to another customer, so it is no longer available.

+

${recipient.item_name}

+

Every piece is one of a kind, so this one will not be restocked. You can browse what is + still available at Redefined Designs.

+

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.

` + ).catch(err => console.error('favorite sold notification failed', err)); + } +} diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 94cadad..6ade4fa 100755 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -8,6 +8,7 @@ import { ADMIN_ITEM_SELECT } from '../itemSelect'; import { asyncRoute } from '../asyncRoute'; import { parseItemFilters, buildItemFilterSql, FilterError } from '../itemFilters'; import { tagColorFor } from '../utils'; +import { notifyFavoritersOfSale } from '../favoriteAlerts'; 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 *`, [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]); }); diff --git a/backend/src/routes/cartCheckout.ts b/backend/src/routes/cartCheckout.ts index 09d96dc..fdd1c51 100644 --- a/backend/src/routes/cartCheckout.ts +++ b/backend/src/routes/cartCheckout.ts @@ -1,6 +1,7 @@ import { Router, Request, Response } from 'express'; import { pool } from '../db'; import { requireCustomer } from '../middleware/customerAuth'; +import { notifyFavoritersOfSale } from '../favoriteAlerts'; const router = 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( `SELECT item_id, price_cents FROM checkout_items WHERE checkout_id = $1`, [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(`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) => { @@ -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' }); 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 notifyFavoritersOfSale(sold.itemIds, sold.buyerId); res.json({ status: 'completed' }); } catch (err) { 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()}`); 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 notifyFavoritersOfSale(sold.itemIds, sold.buyerId); res.json({ status: 'completed' }); } catch (err) { await client.query('ROLLBACK'); @@ -244,8 +255,9 @@ webhookRouter.post('/', async (req: Request, res: Response) => { const client = await pool.connect(); try { 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 notifyFavoritersOfSale(sold.itemIds, sold.buyerId); } catch (e) { await client.query('ROLLBACK'); console.error('webhook completeCheckout failed', e); diff --git a/backend/src/routes/customers.ts b/backend/src/routes/customers.ts index 90f2e39..4b78366 100755 --- a/backend/src/routes/customers.ts +++ b/backend/src/routes/customers.ts @@ -5,6 +5,7 @@ import { pool } from '../db'; import { requireCustomer } from '../middleware/customerAuth'; import { sendMail } from '../mailer'; import { MARKETING_CONSENT_TEXT, isValidEmail } from '../utils'; +import { FAVORITE_ALERTS_CONSENT_TEXT } from '../favoriteAlerts'; import { asyncRoute } from '../asyncRoute'; import { passwordResetRequestLimiter } from '../rateLimit'; @@ -38,6 +39,7 @@ function publicCustomer(c: any) { name: c.name, email_verified: c.email_verified, marketing_consent: c.marketing_consent, + favorite_alerts: c.favorite_alerts, created_at: c.created_at }; } @@ -218,6 +220,51 @@ router.post('/logout', async (req: Request, res: Response) => { 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) => { const { rows } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [req.customerId]); if (!rows.length) return res.status(404).json({ error: 'not found' }); diff --git a/backend/tests/integration/favorites.integration.test.ts b/backend/tests/integration/favorites.integration.test.ts new file mode 100644 index 0000000..37fabba --- /dev/null +++ b/backend/tests/integration/favorites.integration.test.ts @@ -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; + +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, 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([]); + }); +}); diff --git a/backend/tests/integration/setup/testDb.ts b/backend/tests/integration/setup/testDb.ts index 207dece..9736e76 100755 --- a/backend/tests/integration/setup/testDb.ts +++ b/backend/tests/integration/setup/testDb.ts @@ -29,7 +29,7 @@ export async function migrate(): Promise { export async function resetDb(): Promise { await testPool.query(` 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 RESTART IDENTITY CASCADE `); diff --git a/frontend/src/components/ItemCard.tsx b/frontend/src/components/ItemCard.tsx index fbfaabe..0d87b6a 100755 --- a/frontend/src/components/ItemCard.tsx +++ b/frontend/src/components/ItemCard.tsx @@ -1,6 +1,6 @@ import { useState, useRef } from 'react'; -import { Card, Badge, Typography, Carousel, Button, message, Tag } from 'antd'; -import { LeftOutlined, RightOutlined } from '@ant-design/icons'; +import { Card, Badge, Typography, Carousel, Button, message, Tag, Modal, Tooltip } from 'antd'; +import { LeftOutlined, RightOutlined, HeartOutlined, HeartFilled } from '@ant-design/icons'; import type { CarouselRef } from 'antd/es/carousel'; import { Item } from '../api'; import MarkdownView from './MarkdownView'; @@ -8,6 +8,8 @@ import { addToCart } from '../cart/cartApi'; import { useCart } from '../cart/CartContext'; import { useCustomerAuth } from '../customer/CustomerAuthContext'; import AuthPromptModal from '../customer/AuthPromptModal'; +import { useFavorites } from '../customer/FavoritesContext'; +import { addFavorite, removeFavorite, setFavoriteAlerts } from '../customer/favoritesApi'; const { Text, Title } = Typography; @@ -20,10 +22,16 @@ export default function ItemCard({ item, onChanged }: Props) { const carouselRef = useRef(null); const [authModalOpen, setAuthModalOpen] = 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: favoriteIds, refresh: refreshFavorites } = useFavorites(); const inMyCart = itemIds.has(item.id); + const isFavorite = favoriteIds.has(item.id); async function doAddToCart() { setAdding(true); @@ -40,13 +48,71 @@ export default function ItemCard({ item, onChanged }: Props) { } 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) { + setPendingAction('cart'); setAuthModalOpen(true); return; } 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 cover = item.images.length ? ( @@ -87,7 +153,20 @@ export default function ItemCard({ item, onChanged }: Props) { const card = ( - {item.name} +
+ {item.name} + +
{item.category_name && {item.category_name}} {item.tags.length > 0 && ( @@ -102,7 +181,11 @@ export default function ItemCard({ item, onChanged }: Props) { setAuthModalOpen(false)} - onSuccess={() => { setAuthModalOpen(false); doAddToCart(); }} + onSuccess={() => { + setAuthModalOpen(false); + if (pendingAction === 'favorite') doToggleFavorite(); + else doAddToCart(); + }} />
); diff --git a/frontend/src/customer/Account.tsx b/frontend/src/customer/Account.tsx index 3a0571c..3e5d68d 100755 --- a/frontend/src/customer/Account.tsx +++ b/frontend/src/customer/Account.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { Card, Typography, Switch, Button, Table, Modal, message, Space, Divider } from 'antd'; import { useNavigate } from 'react-router-dom'; import { fetchMyOrders, OrderHistoryItem, updateConsent, exportMyData, deleteMyAccount } from './customerApi'; +import { setFavoriteAlerts } from './favoritesApi'; import { useCustomerAuth } from './CustomerAuthContext'; const { Title, Text } = Typography; @@ -21,6 +22,17 @@ export default function Account() { 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) { await updateConsent(checked); message.success(checked ? 'Subscribed to emails' : 'Unsubscribed from emails'); @@ -70,6 +82,15 @@ export default function Account() { Receive emails about new items + {/* A separate consent from marketing above, and shown separately so a + customer can hold one without the other. */} +
+ + + Email me when an item I favorited is sold + +
+ Order History ; + refresh: () => void; +} + +const FavoritesContext = createContext({ + 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([]); + 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 ( + + {children} + + ); +} diff --git a/frontend/src/customer/customerApi.ts b/frontend/src/customer/customerApi.ts index 1ffc8af..db6d535 100755 --- a/frontend/src/customer/customerApi.ts +++ b/frontend/src/customer/customerApi.ts @@ -4,6 +4,7 @@ export interface Customer { name: string | null; email_verified: boolean; marketing_consent: boolean; + favorite_alerts: boolean; created_at: string; } diff --git a/frontend/src/customer/favoritesApi.ts b/frontend/src/customer/favoritesApi.ts new file mode 100644 index 0000000..2691690 --- /dev/null +++ b/frontend/src/customer/favoritesApi.ts @@ -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 { + if (res.ok) return res; + const detail = await res.json().catch(() => ({})); + throw new Error(detail.error || action); +} + +export async function fetchFavorites(): Promise { + 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 { + await expectOk( + await fetch(`/api/customers/me/favorites/${itemId}`, { method: 'POST' }), + 'failed to save favorite' + ); +} + +export async function removeFavorite(itemId: number): Promise { + await expectOk( + await fetch(`/api/customers/me/favorites/${itemId}`, { method: 'DELETE' }), + 'failed to remove favorite' + ); +} + +export async function setFavoriteAlerts(enabled: boolean): Promise { + 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(); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 305db59..8baad82 100755 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -15,6 +15,7 @@ import ResetPassword from './customer/ResetPassword'; import Cart from './cart/Cart'; import { CustomerAuthProvider } from './customer/CustomerAuthContext'; import { CartProvider } from './cart/CartContext'; +import { FavoritesProvider } from './customer/FavoritesContext'; import { ThemeModeProvider, useThemeMode } from './theme/ThemeContext'; import './styles.css'; @@ -87,7 +88,9 @@ ReactDOM.createRoot(document.getElementById('root')!).render( - + + + diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 0ea323f..057cfa6 100755 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -142,3 +142,16 @@ body { margin: 0; } .admin-category-node .ant-space { 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; +} diff --git a/frontend/tests/e2e/favorites.spec.ts b/frontend/tests/e2e/favorites.spec.ts new file mode 100644 index 0000000..7108839 --- /dev/null +++ b/frontend/tests/e2e/favorites.spec.ts @@ -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); + }); +}); -- 2.54.0