feat: favorite items and notify when a favorite is sold (#34)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<CarouselRef>(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 = (
|
||||
<Card hoverable cover={cover} className="item-card">
|
||||
<Title level={5} style={{ marginBottom: 4 }}>{item.name}</Title>
|
||||
<div className="item-card-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>}
|
||||
<MarkdownView content={item.description} />
|
||||
{item.tags.length > 0 && (
|
||||
@@ -102,7 +181,11 @@ export default function ItemCard({ item, onChanged }: Props) {
|
||||
<AuthPromptModal
|
||||
open={authModalOpen}
|
||||
onClose={() => setAuthModalOpen(false)}
|
||||
onSuccess={() => { setAuthModalOpen(false); doAddToCart(); }}
|
||||
onSuccess={() => {
|
||||
setAuthModalOpen(false);
|
||||
if (pendingAction === 'favorite') doToggleFavorite();
|
||||
else doAddToCart();
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -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() {
|
||||
<Text>Receive emails about new items</Text>
|
||||
</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 />
|
||||
<Title level={5}>Order History</Title>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export interface Customer {
|
||||
name: string | null;
|
||||
email_verified: boolean;
|
||||
marketing_consent: boolean;
|
||||
favorite_alerts: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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(
|
||||
<ThemeModeProvider>
|
||||
<CustomerAuthProvider>
|
||||
<CartProvider>
|
||||
<Root />
|
||||
<FavoritesProvider>
|
||||
<Root />
|
||||
</FavoritesProvider>
|
||||
</CartProvider>
|
||||
</CustomerAuthProvider>
|
||||
</ThemeModeProvider>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user