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>
246 lines
8.1 KiB
TypeScript
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>
|
|
);
|
|
};
|