feat(frontend): add react spa with wishlist flows and public profile

This commit is contained in:
Anton
2026-04-23 16:05:27 +03:00
parent 5f6a551b6c
commit 00f01611ed
44 changed files with 2166 additions and 0 deletions

View File

@@ -0,0 +1,191 @@
import { memo, useState } from 'react';
import type { Wish } from '@family-wishlist/shared';
import {
Archive,
CheckCircle2,
Copy,
ExternalLink,
MoreHorizontal,
Pencil,
RotateCcw,
Trash2,
} from 'lucide-react';
import { WishBadges } from '../WishBadges/WishBadges';
import { Button } from '../ui/Button';
import { cn } from '@/lib/cn';
import { formatPrice } from '@/lib/format';
export type WishCardView = 'owner' | 'guest';
export interface WishCardAction {
key: string;
icon: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
danger?: boolean;
}
interface WishCardProps {
wish: Wish & { isNewForOwner?: boolean };
view: WishCardView;
onEdit?: () => void;
onArchive?: () => void;
onComplete?: () => void;
onRestore?: () => void;
onDuplicate?: () => void;
onDelete?: () => void;
footer?: React.ReactNode;
}
function WishCardInner({
wish,
view,
onEdit,
onArchive,
onComplete,
onRestore,
onDuplicate,
onDelete,
footer,
}: WishCardProps) {
const [menuOpen, setMenuOpen] = useState(false);
const completed = wish.status === 'COMPLETED';
const priceLabel = formatPrice(wish.price, wish.currency);
const imageSrc = wish.imageUrl ?? '/default-gift.svg';
const actions: WishCardAction[] = [];
if (view === 'owner') {
if (wish.status === 'ACTIVE') {
if (onEdit) actions.push({ key: 'edit', icon: Pencil, label: 'Edit', onClick: onEdit });
if (onComplete)
actions.push({
key: 'complete',
icon: CheckCircle2,
label: 'Mark fulfilled',
onClick: onComplete,
});
if (onArchive)
actions.push({ key: 'archive', icon: Archive, label: 'Archive', onClick: onArchive });
if (onDelete)
actions.push({
key: 'delete',
icon: Trash2,
label: 'Delete',
onClick: onDelete,
danger: true,
});
} else if (wish.status === 'ARCHIVED') {
if (onRestore)
actions.push({ key: 'restore', icon: RotateCcw, label: 'Restore', onClick: onRestore });
if (onDelete)
actions.push({
key: 'delete',
icon: Trash2,
label: 'Delete',
onClick: onDelete,
danger: true,
});
} else if (wish.status === 'COMPLETED') {
if (onDuplicate)
actions.push({
key: 'duplicate',
icon: Copy,
label: 'Create copy as new',
onClick: onDuplicate,
});
if (onDelete)
actions.push({
key: 'delete',
icon: Trash2,
label: 'Delete',
onClick: onDelete,
danger: true,
});
} else if (wish.status === 'DELETED') {
if (onRestore)
actions.push({ key: 'restore', icon: RotateCcw, label: 'Restore', onClick: onRestore });
}
}
return (
<article className={cn('wish-card', completed && 'wish-card--completed')}>
<div className="wish-card__image-wrap">
<img
className="wish-card__image"
src={imageSrc}
alt={wish.title}
loading="lazy"
onError={(e) => {
(e.currentTarget as HTMLImageElement).src = '/default-gift.svg';
}}
/>
<div className="absolute left-3 top-3">
<WishBadges wish={wish} view={view} />
</div>
{actions.length > 0 && (
<div className="absolute right-3 top-3">
<div className="relative">
<Button
variant="secondary"
size="icon"
aria-label="Actions"
onClick={() => setMenuOpen((v) => !v)}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
{menuOpen && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setMenuOpen(false)}
aria-hidden
/>
<div className="absolute right-0 z-20 mt-2 w-48 overflow-hidden rounded-md border border-border bg-surface shadow-pop">
{actions.map((a) => (
<button
key={a.key}
type="button"
onClick={() => {
setMenuOpen(false);
a.onClick();
}}
className={cn(
'flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-surface-muted',
a.danger && 'text-danger',
)}
>
<a.icon className="h-4 w-4" />
{a.label}
</button>
))}
</div>
</>
)}
</div>
</div>
)}
</div>
<div className="wish-card__body">
<h3 className="wish-card__title">{wish.title}</h3>
<div className="flex items-center gap-2">
{priceLabel && <span className="wish-card__price">{priceLabel}</span>}
{wish.url && (
<a
href={wish.url}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:text-primary-600"
>
<ExternalLink className="h-3 w-3" />
open link
</a>
)}
</div>
{wish.comment && <p className="wish-card__comment">{wish.comment}</p>}
{footer}
</div>
</article>
);
}
export const WishCard = memo(WishCardInner);