feat(frontend): add react spa with wishlist flows and public profile
This commit is contained in:
191
apps/frontend/src/components/WishCard/WishCard.tsx
Normal file
191
apps/frontend/src/components/WishCard/WishCard.tsx
Normal 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);
|
||||
Reference in New Issue
Block a user