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); + }); +});