Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
316 changes: 185 additions & 131 deletions src/App.jsx

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions src/components/FavoriteRoasterButton.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { useFavoriteRoasters } from '../hooks/useFavoriteRoasters.jsx';
import Icon from './Icon.jsx';

/**
* Bookmark toggle that pins/unpins a roaster (per-user favorite).
* Heart toggle that pins/unpins a roaster (per-user favorite).
* Pinned roasters sort to the top of the directory and appear in the
* account page. Hidden when signed out — same convention as WishlistHeart.
* account page. A favourite is affection, so it gets the heart; the
* bean wishlist ("buy later") gets the bookmark (WishlistHeart). Hidden
* when signed out — same convention as WishlistHeart.
*/
export default function FavoriteRoasterButton({ roaster, size = 18, className = '' }) {
const { user } = useAuth();
Expand All @@ -27,12 +29,10 @@ export default function FavoriteRoasterButton({ roaster, size = 18, className =
toggle(roaster);
}}
className={`inline-flex items-center justify-center rounded-md p-1.5 transition-colors ${
pinned
? 'text-accent hover:text-accent-hover'
: 'text-fg-subtle hover:text-accent'
pinned ? 'text-accent hover:text-accent-hover' : 'text-fg-subtle hover:text-accent'
} ${className}`}
>
<Icon name="bookmark" size={size} className={pinned ? 'fill-current' : ''} />
<Icon name="heart" size={size} className={pinned ? 'fill-current' : ''} />
</button>
);
}
19 changes: 12 additions & 7 deletions src/components/WishlistHeart.jsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { useAuth } from '../auth.jsx';
import { useWishlist } from '../hooks/useWishlist.jsx';
import Icon from './Icon.jsx';

/**
* Heart icon that toggles a coffee in the user's wishlist (Q10).
* Hidden when the user isn't logged in — prevents a click-to-redirect
* dead end.
* Bookmark toggle that adds/removes a coffee from the user's wishlist (Q10).
* A wishlist is "come back to this later" — a bookmark, the same glyph
* Vivino/Untappd use for "want" — not "I love this", so it is deliberately
* NOT a heart and NOT red. The heart belongs to roaster favourites
* (FavoriteRoasterButton). Hidden when the user isn't logged in — prevents
* a click-to-redirect dead end.
*/
export default function WishlistHeart({ coffeeId, size = 'md' }) {
const { user } = useAuth();
Expand All @@ -13,23 +17,24 @@ export default function WishlistHeart({ coffeeId, size = 'md' }) {

const wishlisted = ids.has(coffeeId);
const label = wishlisted ? 'Remove from wishlist' : 'Add to wishlist';
const px = size === 'lg' ? 'text-2xl' : 'text-lg';
const px = size === 'lg' ? 24 : 20;

return (
<button
type="button"
title={label}
aria-label={label}
aria-pressed={wishlisted}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
wishlisted ? remove(coffeeId) : add(coffeeId);
}}
className={`${px} transition-colors leading-none ${
wishlisted ? 'text-red-500 hover:text-red-600 dark:text-red-400' : 'text-fg-subtle hover:text-red-400'
className={`inline-flex items-center justify-center rounded-md p-1 transition-colors ${
wishlisted ? 'text-accent hover:text-accent-hover' : 'text-fg-subtle hover:text-accent'
}`}
>
{wishlisted ? '♥' : '♡'}
<Icon name="bookmark" size={px} className={wishlisted ? 'fill-current' : ''} />
</button>
);
}
78 changes: 78 additions & 0 deletions src/components/WishlistHeart.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import WishlistHeart from './WishlistHeart.jsx';

const mockAuth = { user: { id: 1, email: 'a@example.com' } };
vi.mock('../auth.jsx', () => ({
useAuth: () => mockAuth,
}));

const add = vi.fn();
const remove = vi.fn();
let wishlistIds = new Set();
vi.mock('../hooks/useWishlist.jsx', () => ({
useWishlist: () => ({ ids: wishlistIds, add, remove }),
}));

describe('WishlistHeart', () => {
afterEach(() => {
cleanup();
add.mockClear();
remove.mockClear();
wishlistIds = new Set();
mockAuth.user = { id: 1, email: 'a@example.com' };
});

it('renders an accessible unpressed toggle when not wishlisted', () => {
render(<WishlistHeart coffeeId={42} />);
const btn = screen.getByRole('button', { name: 'Add to wishlist' });
expect(btn).toHaveAttribute('aria-pressed', 'false');
});

it('renders a pressed toggle when wishlisted', () => {
wishlistIds = new Set([42]);
render(<WishlistHeart coffeeId={42} />);
expect(screen.getByRole('button', { name: 'Remove from wishlist' })).toHaveAttribute(
'aria-pressed',
'true',
);
});

it('uses the bookmark glyph, not a heart — a wishlist is "buy later", not "love"', () => {
const { container } = render(<WishlistHeart coffeeId={42} />);
expect(container.querySelector('svg')).not.toBeNull();
expect(container.textContent).not.toMatch(/[♥♡]/);
// Favourite roasters own the heart; the two must stay distinct.
expect(container.querySelector('button').className).not.toMatch(/red/);
});

it('adds when not wishlisted and removes when wishlisted', () => {
const { unmount } = render(<WishlistHeart coffeeId={42} />);
fireEvent.click(screen.getByRole('button', { name: 'Add to wishlist' }));
expect(add).toHaveBeenCalledWith(42);
expect(remove).not.toHaveBeenCalled();
unmount();

wishlistIds = new Set([42]);
render(<WishlistHeart coffeeId={42} />);
fireEvent.click(screen.getByRole('button', { name: 'Remove from wishlist' }));
expect(remove).toHaveBeenCalledWith(42);
});

it('does not bubble the click to a wrapping card', () => {
const onCardClick = vi.fn();
render(
<div onClick={onCardClick}>
<WishlistHeart coffeeId={42} />
</div>,
);
fireEvent.click(screen.getByRole('button', { name: 'Add to wishlist' }));
expect(onCardClick).not.toHaveBeenCalled();
});

it('renders nothing when signed out', () => {
mockAuth.user = null;
const { container } = render(<WishlistHeart coffeeId={42} />);
expect(container).toBeEmptyDOMElement();
});
});
125 changes: 101 additions & 24 deletions src/pages/AccountSettings.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ function fieldErrors(err) {
const errors = err?.body?.errors;
if (!errors) return {};
return Object.fromEntries(
Object.entries(errors).map(([k, v]) => [k, Array.isArray(v) ? v[0] : String(v)])
Object.entries(errors).map(([k, v]) => [k, Array.isArray(v) ? v[0] : String(v)]),
);
}

Expand Down Expand Up @@ -115,11 +115,20 @@ function ProfileSection({ user, token, setUser, snack }) {
<SectionCard title="Profile">
<form onSubmit={submit} className="space-y-4">
<Field label="Name" error={errors.name}>
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} required />
<input
className={inputClass}
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</Field>
<Field
label="Username"
hint={displayName ? `Your public profile: /u/${displayName}` : 'Letters, numbers, hyphens, and underscores.'}
hint={
displayName
? `Your public profile: /u/${displayName}`
: 'Letters, numbers, hyphens, and underscores.'
}
error={errors.display_name}
>
<input
Expand All @@ -133,7 +142,11 @@ function ProfileSection({ user, token, setUser, snack }) {
</Field>
{user.avatar_url && (
<p className="text-xs text-fg-subtle flex items-center gap-2">
<img src={user.avatar_url} alt="" className="w-6 h-6 rounded-full border border-border" />
<img
src={user.avatar_url}
alt=""
className="w-6 h-6 rounded-full border border-border"
/>
Profile photo is managed by your Google account.
</p>
)}
Expand All @@ -157,8 +170,12 @@ function PinnedRoastersSection() {
>
{items.length === 0 ? (
<p className="text-sm text-fg-muted">
Nothing pinned yet. Hit the <Icon name="bookmark" size={14} className="inline -mt-0.5" /> on any
roaster in the <Link to="/roasters" className="text-accent hover:underline">directory</Link>.
Nothing pinned yet. Hit the <Icon name="heart" size={14} className="inline -mt-0.5" /> on
any roaster in the{' '}
<Link to="/roasters" className="text-accent hover:underline">
directory
</Link>
.
</p>
) : (
<ul className="divide-y divide-border">
Expand All @@ -169,10 +186,15 @@ function PinnedRoastersSection() {
<li key={it.id} className="py-2.5 flex items-center gap-3">
<RoasterAvatar name={r.name} faviconUrl={r.favicon_url} size={32} />
<div className="flex-1 min-w-0">
<Link to={`/beans?roaster=${r.slug}`} className="font-medium text-fg hover:text-accent hover:underline truncate block">
<Link
to={`/beans?roaster=${r.slug}`}
className="font-medium text-fg hover:text-accent hover:underline truncate block"
>
{r.name}
</Link>
<span className="text-xs text-fg-subtle">{[r.city, r.region].filter(Boolean).join(', ')}</span>
<span className="text-xs text-fg-subtle">
{[r.city, r.region].filter(Boolean).join(', ')}
</span>
</div>
<button
type="button"
Expand Down Expand Up @@ -232,10 +254,23 @@ function EmailSection({ user, token, setUser, snack }) {
)}
<form onSubmit={submit} className="space-y-4">
<Field label="New email" error={errors.email}>
<input type="email" className={inputClass} value={email} onChange={(e) => setEmail(e.target.value)} required />
<input
type="email"
className={inputClass}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</Field>
<Field label="Current password" error={errors.current_password}>
<input type="password" className={inputClass} value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" required />
<input
type="password"
className={inputClass}
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</Field>
<GooglePasswordHint user={user} />
<button type="submit" disabled={saving} className={primaryBtn}>
Expand Down Expand Up @@ -280,19 +315,39 @@ function PasswordSection({ user, token, snack }) {
}

return (
<SectionCard
title="Password"
subtitle="Changing your password signs out every other device."
>
<SectionCard title="Password" subtitle="Changing your password signs out every other device.">
<form onSubmit={submit} className="space-y-4">
<Field label="Current password" error={errors.current_password}>
<input type="password" className={inputClass} value={current} onChange={(e) => setCurrent(e.target.value)} autoComplete="current-password" required />
<input
type="password"
className={inputClass}
value={current}
onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password"
required
/>
</Field>
<Field label="New password" hint="At least 8 characters." error={errors.password}>
<input type="password" className={inputClass} value={next} onChange={(e) => setNext(e.target.value)} autoComplete="new-password" minLength={8} required />
<input
type="password"
className={inputClass}
value={next}
onChange={(e) => setNext(e.target.value)}
autoComplete="new-password"
minLength={8}
required
/>
</Field>
<Field label="Confirm new password">
<input type="password" className={inputClass} value={confirm} onChange={(e) => setConfirm(e.target.value)} autoComplete="new-password" minLength={8} required />
<input
type="password"
className={inputClass}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
autoComplete="new-password"
minLength={8}
required
/>
</Field>
<GooglePasswordHint user={user} />
<button type="submit" disabled={saving} className={primaryBtn}>
Expand All @@ -309,7 +364,9 @@ function GooglePasswordHint({ user }) {
return (
<p className="text-xs text-fg-muted bg-surface-muted border border-border rounded-lg p-2.5">
Signed in with Google and never set a password? Use{' '}
<Link to="/forgot-password" className="text-accent hover:underline">forgot password</Link>{' '}
<Link to="/forgot-password" className="text-accent hover:underline">
forgot password
</Link>{' '}
to create one first.
</p>
);
Expand All @@ -321,8 +378,16 @@ function ShortcutsSection({ user }) {
return (
<SectionCard title="Your stuff">
<ul className="space-y-2 text-sm">
<li><Link to="/me" className="text-accent hover:underline">Your tastings</Link></li>
<li><Link to="/me/wishlist" className="text-accent hover:underline">Your wishlist</Link></li>
<li>
<Link to="/me" className="text-accent hover:underline">
Your tastings
</Link>
</li>
<li>
<Link to="/me/wishlist" className="text-accent hover:underline">
Your wishlist
</Link>
</li>
{user.display_name && (
<li>
<Link to={`/u/${user.display_name}`} className="text-accent hover:underline">
Expand Down Expand Up @@ -364,11 +429,20 @@ function DangerZone({ user, token, setAuthToken }) {
<Card padding="lg" className="border-danger/40">
<h2 className="text-base font-semibold text-danger">Danger zone</h2>
<p className="mt-0.5 text-sm text-fg-muted">
Deleting your account permanently removes your tastings, wishlist, and pinned
roasters. This cannot be undone.
Deleting your account permanently removes your tastings, wishlist, and pinned roasters. This
cannot be undone.
</p>
<div className="mt-4">
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) { setPassword(''); setErrors({}); } }}>
<Dialog
open={open}
onOpenChange={(v) => {
setOpen(v);
if (!v) {
setPassword('');
setErrors({});
}
}}
>
<Dialog.Trigger asChild>
<button
type="button"
Expand All @@ -395,7 +469,10 @@ function DangerZone({ user, token, setAuthToken }) {
<GooglePasswordHint user={user} />
<div className="flex justify-end gap-2">
<Dialog.Close asChild>
<button type="button" className="px-4 py-2.5 rounded-lg border border-border text-fg text-sm font-medium hover:bg-surface-muted transition-colors">
<button
type="button"
className="px-4 py-2.5 rounded-lg border border-border text-fg text-sm font-medium hover:bg-surface-muted transition-colors"
>
Cancel
</button>
</Dialog.Close>
Expand Down
Loading
Loading