veza/apps/web/src/components/commerce/WishlistView.tsx
senke 8e9ee2f3a5 fix: stabilize builds, tests, and lint across all stacks
Complete stabilization pass bringing all 3 stacks to green:

Frontend (apps/web/):
- Fix TypeScript nullability in useSeason.ts, useTimeOfDay.ts hooks
- Disable no-undef in ESLint config (TypeScript handles it; JSX misidentified)
- Rename 306 story imports from @storybook/react to @storybook/react-vite
- Fix conditional hook call in useMediaQuery.ts useIsTablet
- Move useQuery to top of LoginPage.tsx component
- Remove useless try/catch in GearFormModal.tsx
- Fix stale closure in ResetPasswordPage.tsx handleChange
- Make Storybook decorators (withRouter, withQueryClient, withToast, withAudio)
  no-ops since global StorybookDecorator already provides these — prevents
  nested Router / duplicate provider crashes in vitest-browser
- Fix nested MemoryRouter in 3 page stories (TrackDetail, PlaylistDetail, UserProfile)
- Update i18n initialization in test setup (await init before changeLanguage)
- Update ~30 test assertions from English to French to match i18n translations
- Update test assertions to match SUMI V3 design changes (shadow vs border)
- Fix remaining story type errors (PlayerError, PlaylistBatchActions,
  TrackFilters, VirtualizedChatMessages)

Backend (veza-backend-api/):
- Fix response_test.go RespondWithAppError signature (2 args, not 3)
- Fix TestErrorContractAuthEndpoints expected error codes
  (ErrCodeUnauthorized vs ErrCodeInvalidCredentials)
- Fix TestTrackHandler_GetTrackLikes_Success missing auth middleware setup
- Fix TestPlaybackAnalyticsService_GetTrackStats k-anonymity threshold
  (needs 5 unique users, not 1)
- Replace NOW() PostgreSQL function with time.Now() parameter in marketplace
  service for SQLite test compatibility
- Add missing AutoMigrate entries in marketplace_test.go
  (ProductImage, ProductPreview, ProductLicense, ProductReview)

Results:
- Frontend TypeCheck: 617 errors -> 0 errors
- Frontend ESLint: 349 errors -> 0 errors
- Frontend Vitest: 196 failing tests -> 1 skipped (3396/3397 passing)
- Backend go vet: 1 error -> 0 errors
- Backend tests: 5 failing -> all 13 packages passing
- Rust: 150/150 tests passing (unchanged)
- Storybook audit: 0 errors across 1244 stories

Triage report: docs/TRIAGE_REPORT.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:48:07 +02:00

246 lines
8.1 KiB
TypeScript

import { Button } from '../ui/button';
import { Card } from '../ui/card';
import { EmptyState } from '../ui/empty-state';
import { Skeleton } from '../ui/skeleton';
import { useCartStore } from '../../stores/cartStore';
import { Product } from '../../types';
import { Heart, Pause, Play, ShoppingCart, Trash2, Zap } from 'lucide-react';
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import { useToast } from '../../components/feedback/ToastProvider';
import { useAuthStore } from '@/features/auth/store/authStore';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { marketplaceService } from '@/services/marketplaceService';
const gridVariants = {
visible: { transition: { staggerChildren: 0.06, delayChildren: 0.04 } },
};
const cardVariants = {
hidden: { opacity: 0, y: 16, scale: 0.97 },
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: { duration: 0.35, ease: [0.33, 1, 0.68, 1] as const },
},
};
const WISHLIST_QUERY_KEY = ['wishlist'];
export const WishlistView: React.FC = () => {
const addToCart = useCartStore((state) => state.addItem);
const { addToast } = useToast();
const queryClient = useQueryClient();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const [playingPreview, setPlayingPreview] = useState<string | null>(null);
const {
data: wishlist = [],
isLoading,
isError,
error,
} = useQuery({
queryKey: WISHLIST_QUERY_KEY,
queryFn: () => marketplaceService.getWishlist(),
enabled: isAuthenticated,
});
const removeMutation = useMutation({
mutationFn: (productId: string) => marketplaceService.removeFromWishlist(productId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: WISHLIST_QUERY_KEY });
addToast('Removed from wishlist', 'info');
},
onError: (err: Error) => {
addToast(err.message || 'Failed to remove from wishlist', 'error');
},
});
const handleRemove = (id: string) => {
if (!isAuthenticated) return;
removeMutation.mutate(id);
};
const handleAddToCart = (product: Product) => {
addToCart(product);
handleRemove(product.id);
};
const handleAddAll = () => {
wishlist.forEach((p) => addToCart(p));
wishlist.forEach((p) => handleRemove(p.id));
addToast('All items moved to cart', 'success');
};
if (!isAuthenticated) {
return (
<EmptyState
variant="centered"
icon={<Heart className="w-full h-full" />}
title="Sign in to view your wishlist"
description="Create an account or log in to save items you love."
action={{
label: 'Sign in',
onClick: () => (window.location.href = '/login'),
}}
size="lg"
className="min-h-96"
/>
);
}
if (isError) {
return (
<EmptyState
variant="centered"
icon={<Heart className="w-full h-full" />}
title="Could not load wishlist"
description={error instanceof Error ? error.message : 'Something went wrong.'}
action={{
label: 'Try again',
onClick: () => queryClient.invalidateQueries({ queryKey: WISHLIST_QUERY_KEY }),
}}
size="lg"
className="min-h-96"
/>
);
}
if (isLoading) {
return (
<div className="max-w-6xl mx-auto pb-20">
<div className="flex flex-col md:flex-row justify-between items-end border-b border-border/50 pb-6 gap-4 mb-8">
<div>
<Skeleton className="h-9 w-48 mb-2" />
<Skeleton variant="text" className="w-32" />
</div>
<Skeleton className="h-10 w-44" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-96 w-full rounded-lg" />
))}
</div>
</div>
);
}
if (wishlist.length === 0) {
return (
<EmptyState
variant="centered"
icon={<Heart className="w-full h-full" />}
title="Your wishlist is empty"
description="Explore the marketplace and save items you love."
action={{
label: 'Browse Marketplace',
onClick: () => (window.location.href = '/marketplace'),
}}
size="lg"
className="min-h-96"
/>
);
}
return (
<div className="animate-fadeIn max-w-6xl mx-auto pb-20">
<div className="flex flex-col md:flex-row justify-between items-end border-b border-border/50 pb-6 gap-4 mb-8">
<div>
<h1 className="text-3xl font-heading font-bold text-foreground mb-2 tracking-tight">
WISHLIST
</h1>
<p className="text-muted-foreground font-mono text-sm">
{wishlist.length} saved items
</p>
</div>
<Button
variant="primary"
icon={<ShoppingCart className="w-4 h-4" />}
onClick={handleAddAll}
>
ADD ALL TO CART
</Button>
</div>
<motion.div
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
variants={gridVariants}
initial="hidden"
animate="visible"
>
{wishlist.map((product) => (
<motion.div key={product.id} variants={cardVariants}>
<Card
variant="default"
className="p-4 group hover:shadow-[0_0_12px_rgba(26,26,30,0.08)] transition-all duration-[var(--sumi-duration-normal)]"
>
<div className="flex gap-4">
<div className="relative w-24 h-24 bg-muted rounded-lg overflow-hidden flex-shrink-0">
<img
src={product.coverUrl}
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-[var(--sumi-duration-slow)]"
/>
<div
className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
onClick={() =>
setPlayingPreview(
playingPreview === product.id ? null : product.id,
)
}
>
{playingPreview === product.id ? (
<Pause className="w-8 h-8 text-foreground" />
) : (
<Play className="w-8 h-8 text-foreground fill-current" />
)}
</div>
{product.isHot && (
<div className="absolute top-1 left-1 bg-warning text-warning-foreground text-xs font-bold px-1.5 py-0.5 rounded">
<Zap className="w-2 h-2 inline" /> HOT
</div>
)}
</div>
<div className="flex-1 min-w-0 flex flex-col justify-between">
<div>
<h3 className="font-bold text-foreground truncate">
{product.title}
</h3>
<p className="text-xs text-muted-foreground truncate">
{product.author}
</p>
<p className="text-xs text-muted-foreground mt-1 capitalize">
{product.type}
</p>
</div>
<div className="text-lg font-mono font-bold text-muted-foreground">
${product.price}
</div>
</div>
</div>
<div className="flex gap-2 mt-4 pt-4 border-t border-border/30">
<Button
variant="secondary"
size="sm"
className="flex-1"
onClick={() => handleAddToCart(product)}
>
Add to Cart
</Button>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive"
onClick={() => handleRemove(product.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</Card>
</motion.div>
))}
</motion.div>
</div>
);
};