chore(storybook): improve configuration and cleanup

This commit is contained in:
senke 2026-02-04 00:44:40 +01:00
parent a2576c4eae
commit d2ae91ac25
35 changed files with 398 additions and 445 deletions

View file

@ -16,6 +16,7 @@ const config: StorybookConfig = {
getAbsolutePath('@storybook/addon-a11y'),
getAbsolutePath('@storybook/addon-interactions'),
],
"staticDirs": ['../public'],
"framework": getAbsolutePath('@storybook/react-vite'),
"docs": {
"defaultName": "Documentation"

View file

@ -1,22 +0,0 @@
import type { Preview } from '@storybook/react-vite';
import '../src/index.css';
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
a11y: {
// 'todo' - show a11y violations in the test UI only
// 'error' - fail CI on a11y violations
// 'off' - skip a11y checks entirely
test: 'todo',
},
},
};
export default preview;

View file

@ -1,9 +1,15 @@
import type { Preview } from '@storybook/react-vite';
import type { Preview } from '@storybook/react';
import '../src/index.css';
import '../src/styles/design-system.css';
import '../src/styles/global-effects.css';
import '../src/styles/header.css';
import '../src/lib/i18n'; // Initialize i18n
import React from 'react';
import { ThemeProvider } from '../src/components/theme/ThemeProvider';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../src/components/feedback/ToastProvider';
import { AudioProvider } from '../src/context/AudioContext';
// Create a client for stories
const queryClient = new QueryClient({
@ -50,9 +56,6 @@ const preview: Preview = {
expanded: true,
},
a11y: {
// 'todo' - show a11y violations in the test UI only
// 'error' - fail CI on a11y violations
// 'off' - skip a11y checks entirely
test: 'todo',
},
viewport: {
@ -77,15 +80,19 @@ const preview: Preview = {
// Apply dark class based on background selection
const isDark = context.globals.backgrounds?.value !== '#ffffff';
return (
<div className={isDark ? 'dark' : ''}>
<QueryClientProvider client={queryClient}>
<ToastProvider>
<MemoryRouter>
<Story />
</MemoryRouter>
</ToastProvider>
</QueryClientProvider>
</div>
<ThemeProvider defaultTheme={isDark ? 'dark' : 'light'}>
<div className={isDark ? 'dark' : ''}>
<QueryClientProvider client={queryClient}>
<ToastProvider>
<AudioProvider>
<MemoryRouter>
<Story />
</MemoryRouter>
</AudioProvider>
</ToastProvider>
</QueryClientProvider>
</div>
</ThemeProvider>
);
},
],

View file

@ -156,7 +156,7 @@ See `eslint.config.js` for full rule configuration.
## Documentation
- **Architecture**: See `docs/` directory
- **Architecture Guide**: `docs/ARCHITECTURE.md` (MUST READ)
- **Component Usage**: `src/components/COMPONENT_USAGE.md`
- **Color Usage**: `src/styles/COLOR_USAGE.md`
- **Typography**: `src/styles/TYPOGRAPHY_GUIDE.md`

View file

@ -0,0 +1,120 @@
# Veza Frontend Architecture Guide
**Status:** Living Document
**Version:** 2.0 (Post-Audit 2026)
This document outlines the architectural principles, patterns, and rules that govern the Veza frontend. It supersedes previous ad-hoc audit reports.
---
## 1. Core Philosophy: "Visual First"
> "If it can't be rendered in Storybook, it is architecturally broken."
We follow a **Storybook-Driven Development** (SDD) approach.
- **Isolation:** Every component must be renderable in isolation. Dependencies (Providers, Router, Store) must be explicit.
- **Verification:** Storybook is our primary verification tool for UI logic and layout.
---
## 2. State Management Strategy
We distinguish three types of state. Mixing them is strictly forbidden.
### 2.1. Server State (Data) -> `React Query`
Data that belongs to the backend (Users, Tracks, Playlists).
- **Tool:** `@tanstack/react-query`
- **Rule:** Never copy server data into a global store (Zustand) unless strictly necessary for client-side manipulation (e.g., a complex audio editor buffer).
- **Caching:** Managed automatically by query keys.
### 2.2. Client Global State (App) -> `Zustand`
Data that is truly global to the client session (Auth Token, Shopping Cart, Audio Player Status).
- **Tool:** `zustand`
- **Rule:** Use atomic selectors to prevent render-thrashing.
- **Structure:**
- `authStore`: User session.
- `cartStore`: E-commerce state (Items, Total).
- `playerStore`: Audio playback state.
**Legacy Note:** `React Context` is BANNED for high-frequency state updates. It is reserved for dependency injection (Theme, i18n).
### 2.3. UI Local State -> `useState` / `useReducer`
Ephemeral state specific to a component (Modal Open/Close, Form Inputs).
- **Rule:** If it doesn't need to persist when navigating away, it stays local.
---
## 3. Component Engineering
We adhere to the **Smart vs Dumb** (Container vs Presentational) separation to ensure testability.
### 3.1. Dumb Components (UI)
- **Role:** Render props into HTML. Emit events via callbacks.
- **Dependencies:** ZERO. No explicit side-effects, no API calls, no Context consumers (except Theme).
- **Testing:** Storybook.
```tsx
// ✅ Correct Dumb Component
export const ProductCard = ({ title, price, onAddToCart }: Props) => (
<div onClick={onAddToCart}>{title} - {price}</div>
);
```
### 3.2. Smart Components (Containers)
- **Role:** Wire data to UI.
- **Dependencies:** Allowed (`useQuery`, `useCartStore`, `useParams`).
- **Testing:** Integration Tests (MSW + Storybook play functions).
```tsx
// ✅ Correct Smart Component
export const ProductCardContainer = ({ id }) => {
const { data } = useProduct(id);
const addToCart = useCartStore(s => s.addItem);
return <ProductCard title={data.title} onAddToCart={() => addToCart(data)} />;
};
```
---
## 4. Design System & Styling
We use **Tailwind CSS** with a rigorous Design System (Kodo).
- **Tokens Only:** Do not use arbitrary values (e.g., `w-[350px]`). Use design tokens (`w-sidebar`).
- **Dark Mode:** All UI/Layout components must implement `dark:` variants.
- **Icons:** `lucide-react`. Icons must inherit color via `currentColor`.
---
## 5. Storybook Usage
Storybook is not optional. It is the definition of "Done".
### 5.1. Decorators
Use granular decorators from `src/stories/decorators.tsx` instead of global wrapping in `preview.tsx`.
- `withToast`: Injects ToastProvider.
- `withRouter`: Injects MemoryRouter.
- `withStoreState`: Mocks Zustand state.
### 5.2. Interaction Testing
Critical user flows (e.g., Add to Cart) must have a `.play` function in their story to verify interaction without manual testing.
---
## 6. Testing Pyramid
1. **Unit (Vitest):** Utilities, Store Reducers, Hooks.
2. **Integration (Storybook + Vitest):** Component wiring, Props interface.
3. **E2E (Playwright):** Critical Paths (Login, Checkout, Signup).
---
## 7. Anti-Patterns (Dos & Don'ts)
| ❌ Don't | ✅ Do |
| :--- | :--- |
| `useContext(CartContext)` | `useCartStore(selector)` |
| `w-[17px]` | `w-4` or `w-5` (stick to grid) |
| Props Drilling (> 3 levels) | Composition (Slots) or Context (if static) |
| API calls in `useEffect` | `useQuery` |
| `any` type | Generated types from OpenAPI |

View file

@ -1,5 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react';
import { AdminDashboardView } from './AdminDashboardView';
import { ToastProvider } from '../../components/feedback/ToastProvider';
/**
* AdminDashboardView - Centre de commande admin
@ -21,9 +22,11 @@ const meta: Meta<typeof AdminDashboardView> = {
tags: ['autodocs'],
decorators: [
(Story) => (
<div className="bg-kodo-background min-h-screen p-4">
<Story />
</div>
<ToastProvider>
<div className="bg-kodo-background min-h-screen p-4">
<Story />
</div>
</ToastProvider>
),
],
};

View file

@ -1,5 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react';
import { AdminModerationView } from './AdminModerationView';
import { ToastProvider } from '../../components/feedback/ToastProvider';
/**
* AdminModerationView - Queue de modération
@ -21,9 +22,11 @@ const meta: Meta<typeof AdminModerationView> = {
tags: ['autodocs'],
decorators: [
(Story) => (
<div className="bg-kodo-background min-h-screen p-4">
<Story />
</div>
<ToastProvider>
<div className="bg-kodo-background min-h-screen p-4">
<Story />
</div>
</ToastProvider>
),
],
};

View file

@ -2,6 +2,8 @@ import type { Meta, StoryObj } from '@storybook/react';
import { DashboardLayout } from './DashboardLayout';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ToastProvider } from '@/components/feedback/ToastProvider';
const queryClient = new QueryClient();
const meta = {
@ -12,9 +14,9 @@ const meta = {
(Story) => (
<BrowserRouter>
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<ToastProvider>
<Story />
</TooltipProvider>
</ToastProvider>
</QueryClientProvider>
</BrowserRouter>
),

View file

@ -2,6 +2,8 @@ import type { Meta, StoryObj } from '@storybook/react';
import { Header } from './Header';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ToastProvider } from '@/components/feedback/ToastProvider';
const queryClient = new QueryClient();
const meta = {
@ -12,7 +14,7 @@ const meta = {
(Story) => (
<BrowserRouter>
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<ToastProvider>
<div className="h-screen bg-kodo-void">
{/* Header is fixed, so we need a container that mimics the body */}
<Story />
@ -20,7 +22,7 @@ const meta = {
<h1>Page Content</h1>
</div>
</div>
</TooltipProvider>
</ToastProvider>
</QueryClientProvider>
</BrowserRouter>
),

View file

@ -1,6 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Navbar } from './Navbar';
import { ThemeProvider } from '../../context/ThemeContext';
import { ThemeProvider } from '../theme/ThemeProvider';
const meta: Meta<typeof Navbar> = {
@ -12,7 +12,7 @@ const meta: Meta<typeof Navbar> = {
tags: ['autodocs'],
decorators: [
(Story) => (
<ThemeProvider>
<ThemeProvider defaultTheme="dark">
<div className="min-h-[200px] bg-background">
<Story />
</div>

View file

@ -13,7 +13,7 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useCartStore } from '../../stores/cartStore';
import { useTheme } from '../../context/ThemeContext';
import { useTheme } from '../theme/ThemeProvider';
import { Notification } from '../../types';
import { NotificationBell } from '../notifications/NotificationBell';
@ -52,7 +52,11 @@ const mockNotifications: Notification[] = [
];
export const Navbar: React.FC<NavbarProps> = ({ onNavigate, onLogout }) => {
const { toggleTheme, theme } = useTheme();
const { theme, setTheme } = useTheme();
const toggleTheme = () => {
setTheme(theme === 'dark' ? 'light' : 'dark');
};
// Selector ensures we re-render only when the calculated count changes
const itemCount = useCartStore((state) => state.getItemCount());
const [showUserMenu, setShowUserMenu] = useState(false);

View file

@ -1,6 +1,8 @@
import type { Meta, StoryObj } from '@storybook/react';
import { PlaylistsView } from './PlaylistsView';
import { ToastProvider } from '../../feedback/ToastProvider';
const meta: Meta<typeof PlaylistsView> = {
title: 'Components/Library/Playlists/PlaylistsView',
component: PlaylistsView,
@ -8,9 +10,11 @@ const meta: Meta<typeof PlaylistsView> = {
tags: ['autodocs'],
decorators: [
(Story) => (
<div className="bg-kodo-background p-4 min-h-screen">
<Story />
</div>
<ToastProvider>
<div className="bg-kodo-background p-4 min-h-screen">
<Story />
</div>
</ToastProvider>
),
],
};

View file

@ -3,6 +3,8 @@ import { NotificationMenu } from './NotificationMenu';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import { ToastProvider } from '../feedback/ToastProvider';
const queryClient = new QueryClient();
const meta = {
@ -13,9 +15,11 @@ const meta = {
(Story) => (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<div className="flex justify-end p-20 bg-kodo-ink min-h-[400px]">
<Story />
</div>
<ToastProvider>
<div className="flex justify-end p-20 bg-kodo-ink min-h-[400px]">
<Story />
</div>
</ToastProvider>
</BrowserRouter>
</QueryClientProvider>
),

View file

@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { Card } from '../../ui/card';
import { Button } from '../../ui/button';
import { useTheme } from '../../../context/ThemeContext';
import { useTheme } from '../../../components/theme/ThemeProvider';
import { ThemeVariant } from '../../../types';
import {
Mail,
@ -143,7 +143,16 @@ export const AccountSettings: React.FC = () => {
].map((opt) => (
<button
key={opt.id}
onClick={() => setTheme(opt.id)}
onClick={() => {
const map: Record<ThemeVariant, 'dark' | 'light'> = {
[ThemeVariant.NEON]: 'dark',
[ThemeVariant.LIGHT]: 'light',
[ThemeVariant.GAMING]: 'dark',
[ThemeVariant.NATURE]: 'light',
[ThemeVariant.TERMINAL]: 'dark',
};
setTheme(map[opt.id]);
}}
className={`flex flex-col items-center justify-center gap-2 p-4 rounded-lg border transition-all ${theme === opt.id ? 'bg-kodo-cyan/10 border-kodo-cyan text-kodo-cyan' : 'bg-kodo-ink border-kodo-steel text-kodo-content-dim hover:text-white'}`}
>
{opt.icon}

View file

@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { Card } from '../../ui/card';
import { Button } from '../../ui/button';
import { useTheme } from '../../../context/ThemeContext';
import { useTheme } from '../../../components/theme/ThemeProvider';
import { ThemeVariant } from '../../../types';
import {
Moon,
@ -79,7 +79,16 @@ export const AppearanceSettingsView: React.FC = () => {
].map((opt) => (
<div
key={opt.id}
onClick={() => setTheme(opt.id)}
onClick={() => {
const map: Record<ThemeVariant, 'dark' | 'light'> = {
[ThemeVariant.NEON]: 'dark',
[ThemeVariant.LIGHT]: 'light',
[ThemeVariant.GAMING]: 'dark',
[ThemeVariant.NATURE]: 'light',
[ThemeVariant.TERMINAL]: 'dark', // Gaming treated as dark for now
};
setTheme(map[opt.id]);
}}
className={`
cursor-pointer p-6 rounded-xl border-2 transition-all flex flex-col items-center gap-4 relative
${theme === opt.id ? 'border-kodo-cyan bg-kodo-cyan/5' : 'border-kodo-steel bg-kodo-ink hover:border-kodo-steel'}

View file

@ -121,8 +121,18 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
ref={ref}
{...props}
>
{icon && <span className="flex items-center justify-center pointer-events-none">{icon}</span>}
{children}
{asChild ? (
children
) : (
<>
{icon && (
<span className="flex items-center justify-center pointer-events-none">
{icon}
</span>
)}
{children}
</>
)}
</Comp>
);
},

View file

@ -1,60 +0,0 @@
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ThemeProvider, useTheme } from './ThemeContext';
import { ReactNode } from 'react';
import { ThemeVariant } from '@/types';
const wrapper = ({ children }: { children: ReactNode }) => (
<ThemeProvider>{children}</ThemeProvider>
);
describe('ThemeContext', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
it('should provide theme context', () => {
const { result } = renderHook(() => useTheme(), { wrapper });
expect(result.current).toBeDefined();
expect(result.current).toHaveProperty('theme');
expect(result.current).toHaveProperty('setTheme');
});
it('should have default theme', () => {
const { result } = renderHook(() => useTheme(), { wrapper });
expect(result.current.theme).toBeDefined();
});
it('should change theme', () => {
const { result } = renderHook(() => useTheme(), { wrapper });
const initialTheme = result.current.theme;
act(() => {
result.current.setTheme(ThemeVariant.GAMING);
});
expect(result.current.theme).toBe(ThemeVariant.GAMING);
expect(result.current.theme).not.toBe(initialTheme);
});
it('should toggle theme', () => {
const { result } = renderHook(() => useTheme(), { wrapper });
const initialTheme = result.current.theme;
act(() => {
if (result.current.toggleTheme) {
result.current.toggleTheme();
}
});
// Le thème devrait changer si toggleTheme existe
if (result.current.toggleTheme) {
expect(result.current.theme).not.toBe(initialTheme);
}
});
});

View file

@ -1,135 +0,0 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { ThemeVariant } from '../types';
interface ThemeContextType {
theme: ThemeVariant;
setTheme: (theme: ThemeVariant) => void;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};
// Define color palettes (RGB channels)
const palettes: Record<ThemeVariant, Record<string, string>> = {
[ThemeVariant.NEON]: {
'--kodo-void': '5 5 8',
'--kodo-ink': '10 11 15',
'--kodo-graphite': '18 19 26',
'--kodo-slate': '26 28 38',
'--kodo-steel': '37 40 54',
'--kodo-cyan': '0 255 247',
'--kodo-cyan-dim': '0 196 189',
'--kodo-magenta': '255 0 255',
'--kodo-lime': '184 255 0',
'--kodo-gold': '255 215 0',
'--kodo-red': '255 51 51',
'--kodo-terminal': '0 255 0',
'--kodo-content-highlight': '255 255 255',
'--kodo-content-dim': '156 163 175',
'--kodo-text-main': '243 243 224',
},
[ThemeVariant.GAMING]: {
'--kodo-void': '10 10 10',
'--kodo-ink': '15 15 15',
'--kodo-graphite': '20 20 20',
'--kodo-slate': '30 30 30',
'--kodo-steel': '45 45 45',
'--kodo-cyan': '255 215 0', // Gold acts as primary
'--kodo-cyan-dim': '218 165 32',
'--kodo-magenta': '255 51 51', // Red acts as accent
'--kodo-lime': '255 255 255',
'--kodo-gold': '255 140 0',
'--kodo-red': '139 0 0',
'--kodo-terminal': '50 205 50',
'--kodo-content-highlight': '255 255 255',
'--kodo-content-dim': '156 163 175',
'--kodo-text-main': '243 243 224',
},
[ThemeVariant.NATURE]: {
'--kodo-void': '5 15 5',
'--kodo-ink': '10 20 10',
'--kodo-graphite': '18 31 18',
'--kodo-slate': '26 38 26',
'--kodo-steel': '37 54 37',
'--kodo-cyan': '74 222 128', // Green-400
'--kodo-cyan-dim': '34 197 94',
'--kodo-magenta': '250 204 21', // Yellow-400
'--kodo-lime': '132 204 22',
'--kodo-gold': '234 179 8',
'--kodo-red': '248 113 113',
'--kodo-terminal': '20 83 45',
'--kodo-content-highlight': '255 255 255',
'--kodo-content-dim': '156 163 175',
'--kodo-text-main': '236 253 245',
},
[ThemeVariant.TERMINAL]: {
'--kodo-void': '0 0 0',
'--kodo-ink': '0 17 0',
'--kodo-graphite': '0 26 0',
'--kodo-slate': '0 34 0',
'--kodo-steel': '0 51 0',
'--kodo-cyan': '0 255 0',
'--kodo-cyan-dim': '0 200 0',
'--kodo-magenta': '0 204 0',
'--kodo-lime': '0 255 0',
'--kodo-gold': '0 255 0',
'--kodo-red': '255 0 0',
'--kodo-terminal': '0 255 0',
'--kodo-content-highlight': '0 255 0',
'--kodo-content-dim': '0 170 0',
'--kodo-text-main': '0 255 0',
},
[ThemeVariant.LIGHT]: {
'--kodo-void': '245 247 250',
'--kodo-ink': '255 255 255',
'--kodo-graphite': '255 255 255',
'--kodo-slate': '241 245 249',
'--kodo-steel': '226 232 240',
'--kodo-cyan': '13 148 136', // Teal 600 (Darker for contrast)
'--kodo-cyan-dim': '20 184 166',
'--kodo-magenta': '192 38 211', // Fuchsia 600
'--kodo-lime': '101 163 13', // Lime 600
'--kodo-gold': '202 138 4',
'--kodo-red': '220 38 38',
'--kodo-terminal': '22 101 52',
'--kodo-content-highlight': '17 24 39', // Gray 900
'--kodo-content-dim': '75 85 99', // Gray 600
'--kodo-text-main': '17 24 39',
},
};
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [theme, setTheme] = useState<ThemeVariant>(ThemeVariant.NEON);
useEffect(() => {
const root = document.documentElement;
const colors = palettes[theme];
Object.entries(colors).forEach(([key, value]) => {
root.style.setProperty(key, value as string);
});
}, [theme]);
const toggleTheme = () => {
const variants = Object.values(ThemeVariant);
const currentIndex = variants.indexOf(theme);
const nextIndex = (currentIndex + 1) % variants.length;
setTheme(variants[nextIndex]);
};
return (
<ThemeContext.Provider value={{ theme, setTheme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};

View file

@ -1,22 +1,8 @@
import type { Meta, StoryObj } from '@storybook/react';
import { within, userEvent } from '@storybook/test';
import { ForgotPasswordPage } from './ForgotPasswordPage';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { withRouter, withQueryClient, withToast } from '../../../stories/decorators';
// Create a mocked QueryClient for stories
const createMockQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: Infinity },
mutations: { retry: false },
},
});
/**
* ForgotPasswordPage - Page de récupération de mot de passe
*
* Permet aux utilisateurs de demander un lien de réinitialisation
* de leur mot de passe par email.
*/
const meta: Meta<typeof ForgotPasswordPage> = {
title: 'Pages/Auth/ForgotPasswordPage',
component: ForgotPasswordPage,
@ -30,11 +16,9 @@ const meta: Meta<typeof ForgotPasswordPage> = {
},
tags: ['autodocs'],
decorators: [
(Story) => (
<QueryClientProvider client={createMockQueryClient()}>
<Story />
</QueryClientProvider>
),
withRouter,
withQueryClient,
withToast,
],
};

View file

@ -1,15 +1,8 @@
import type { Meta, StoryObj } from '@storybook/react';
import { within, userEvent, expect, fn } from '@storybook/test';
import { within, userEvent } from '@storybook/test';
import { LoginPage } from './LoginPage';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { withRouter, withQueryClient } from '../../../stories/decorators';
// Create a mocked QueryClient for stories
const createMockQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: Infinity },
mutations: { retry: false },
},
});
/**
* LoginPage - Page de connexion
@ -30,11 +23,8 @@ const meta: Meta<typeof LoginPage> = {
},
tags: ['autodocs'],
decorators: [
(Story) => (
<QueryClientProvider client={createMockQueryClient()}>
<Story />
</QueryClientProvider>
),
withRouter,
withQueryClient,
],
};

View file

@ -1,23 +1,8 @@
import type { Meta, StoryObj } from '@storybook/react';
import { within, userEvent } from '@storybook/test';
import { RegisterPage } from './RegisterPage';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { withRouter, withQueryClient, withToast } from '../../../stories/decorators';
// Create a mocked QueryClient for stories
const createMockQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: Infinity },
mutations: { retry: false },
},
});
/**
* RegisterPage - Page d'inscription
*
* Page complète d'inscription avec formulaire de création de compte,
* validation en temps réel, indicateur de force du mot de passe,
* et vérification de disponibilité du nom d'utilisateur.
*/
const meta: Meta<typeof RegisterPage> = {
title: 'Pages/Auth/RegisterPage',
component: RegisterPage,
@ -31,11 +16,9 @@ const meta: Meta<typeof RegisterPage> = {
},
tags: ['autodocs'],
decorators: [
(Story) => (
<QueryClientProvider client={createMockQueryClient()}>
<Story />
</QueryClientProvider>
),
withRouter,
withQueryClient,
withToast,
],
};

View file

@ -1,20 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react';
import DashboardPage from './DashboardPage';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { withRouter, withQueryClient, withToast } from '../../../stories/decorators';
const createMockQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: Infinity },
mutations: { retry: false },
},
});
/**
* DashboardPage - Tableau de bord utilisateur
*
* Page principale du tableau de bord avec statistiques,
* activité récente, tracks récents et actions rapides.
*/
const meta: Meta<typeof DashboardPage> = {
title: 'Pages/Dashboard/DashboardPage',
component: DashboardPage,
@ -28,12 +15,13 @@ const meta: Meta<typeof DashboardPage> = {
},
tags: ['autodocs'],
decorators: [
withRouter,
withQueryClient,
withToast,
(Story) => (
<QueryClientProvider client={createMockQueryClient()}>
<div className="bg-kodo-background min-h-screen">
<Story />
</div>
</QueryClientProvider>
<div className="bg-kodo-background min-h-screen">
<Story />
</div>
),
],
};

View file

@ -1,4 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react';
import { withRouter } from '../../../stories/decorators';
import NotFoundPage from './NotFoundPage';
/**
@ -19,6 +20,7 @@ const meta: Meta<typeof NotFoundPage> = {
},
},
tags: ['autodocs'],
decorators: [withRouter],
};
export default meta;

View file

@ -1,5 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react';
import { within, userEvent, expect } from '@storybook/test';
import { withRouter } from '../../../stories/decorators';
import ServerErrorPage from './ServerErrorPage';
/**
@ -20,6 +21,7 @@ const meta: Meta<typeof ServerErrorPage> = {
},
},
tags: ['autodocs'],
decorators: [withRouter],
};
export default meta;

View file

@ -3,6 +3,8 @@ import { UploadModal } from './UploadModal';
import { Button } from '@/components/ui/button';
import { useState } from 'react';
import { ToastProvider } from '@/components/feedback/ToastProvider';
const meta = {
title: 'Features/Library/Components/UploadModal',
component: UploadModal,
@ -10,6 +12,13 @@ const meta = {
argTypes: {
isOpen: { control: 'boolean' },
},
decorators: [
(Story) => (
<ToastProvider>
<Story />
</ToastProvider>
),
],
} satisfies Meta<typeof UploadModal>;
export default meta;

View file

@ -1,14 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react';
import { LibraryPage } from './LibraryPage';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
const createMockQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: Infinity },
mutations: { retry: false },
},
});
import { withRouter, withQueryClient, withToast, withAudio } from '../../../stories/decorators';
const meta: Meta<typeof LibraryPage> = {
title: 'Pages/Library/LibraryPage',
@ -16,14 +8,14 @@ const meta: Meta<typeof LibraryPage> = {
parameters: { layout: 'fullscreen' },
tags: ['autodocs'],
decorators: [
withRouter,
withQueryClient,
withToast,
withAudio,
(Story) => (
<QueryClientProvider client={createMockQueryClient()}>
<BrowserRouter>
<div className="bg-kodo-background min-h-screen">
<Story />
</div>
</BrowserRouter>
</QueryClientProvider>
<div className="bg-kodo-background min-h-screen">
<Story />
</div>
),
],
};

View file

@ -1,13 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react';
import { NotificationsPage } from './NotificationsPage';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const createMockQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: Infinity },
mutations: { retry: false },
},
});
import { withRouter, withQueryClient, withToast, withAudio } from '../../../stories/decorators';
const meta: Meta<typeof NotificationsPage> = {
title: 'Pages/Notifications/NotificationsPage',
@ -15,12 +8,14 @@ const meta: Meta<typeof NotificationsPage> = {
parameters: { layout: 'fullscreen' },
tags: ['autodocs'],
decorators: [
withRouter,
withQueryClient,
withToast,
withAudio,
(Story) => (
<QueryClientProvider client={createMockQueryClient()}>
<div className="bg-kodo-background min-h-screen">
<Story />
</div>
</QueryClientProvider>
<div className="bg-kodo-background min-h-screen">
<Story />
</div>
),
],
};

View file

@ -1,6 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react';
import { CreatePlaylistDialog } from './CreatePlaylistDialog';
import { ToastProvider } from '@/components/feedback/ToastProvider';
import { withToast } from '@/stories/decorators';
const meta: Meta<typeof CreatePlaylistDialog> = {
title: 'Features/Playlists/CreatePlaylistDialog',
@ -9,13 +9,7 @@ const meta: Meta<typeof CreatePlaylistDialog> = {
layout: 'centered',
},
tags: ['autodocs'],
decorators: [
(Story) => (
<ToastProvider>
<Story />
</ToastProvider>
),
],
decorators: [withToast],
argTypes: {
onOpenChange: { action: 'onOpenChange' },
onCreated: { action: 'onCreated' },

View file

@ -1,5 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react';
import { PlaylistList } from './PlaylistList';
import { ToastProvider } from '@/components/feedback/ToastProvider';
/**
* PlaylistList - Liste de playlists
@ -21,9 +22,11 @@ const meta: Meta<typeof PlaylistList> = {
tags: ['autodocs'],
decorators: [
(Story) => (
<div className="bg-kodo-background min-h-screen p-4">
<Story />
</div>
<ToastProvider>
<div className="bg-kodo-background min-h-screen p-4">
<Story />
</div>
</ToastProvider>
),
],
};

View file

@ -1,20 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react';
import { PlaylistListPage } from './PlaylistListPage';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { withRouter, withQueryClient, withToast, withAudio } from '../../../stories/decorators';
const createMockQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: Infinity },
mutations: { retry: false },
},
});
/**
* PlaylistListPage - Page de liste des playlists
*
* Page principale affichant toutes les playlists
* avec recherche et filtres.
*/
const meta: Meta<typeof PlaylistListPage> = {
title: 'Pages/Playlists/PlaylistListPage',
component: PlaylistListPage,
@ -28,12 +15,14 @@ const meta: Meta<typeof PlaylistListPage> = {
},
tags: ['autodocs'],
decorators: [
withRouter,
withQueryClient,
withToast,
withAudio,
(Story) => (
<QueryClientProvider client={createMockQueryClient()}>
<div className="bg-kodo-background min-h-screen">
<Story />
</div>
</QueryClientProvider>
<div className="bg-kodo-background min-h-screen">
<Story />
</div>
),
],
};

View file

@ -24,14 +24,16 @@ export const Default: Story = {
return <ViewToggle {...args} value={value} onChange={(newValue) => updateArgs({ value: newValue })} />;
},
args: {
value: 'list',
value: 'list' as const,
showLabels: true,
onChange: () => { },
}
};
export const IconOnly: Story = {
args: {
value: 'grid',
value: 'grid' as const,
showLabels: false,
onChange: () => { },
}
};

View file

@ -1,7 +1,10 @@
import type { Meta, StoryObj } from '@storybook/react';
import { TrackDetailPage } from './TrackDetailPage';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { AudioProvider } from '../../../context/AudioContext';
import { ToastProvider } from '../../../components/feedback/ToastProvider';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
const createMockQueryClient = () => new QueryClient({
defaultOptions: {
@ -10,32 +13,24 @@ const createMockQueryClient = () => new QueryClient({
},
});
/**
* TrackDetailPage - Page détail d'un track
*
* Page complète affichant les détails d'un track avec
* waveform, métadonnées et actions.
*/
const meta: Meta<typeof TrackDetailPage> = {
title: 'Pages/Tracks/TrackDetailPage',
component: TrackDetailPage,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: 'Page de détail d\'un track audio.',
},
},
},
tags: ['autodocs'],
decorators: [
(Story) => (
<QueryClientProvider client={createMockQueryClient()}>
<MemoryRouter initialEntries={['/tracks/demo-track']}>
<Routes>
<Route path="/tracks/:id" element={<Story />} />
</Routes>
</MemoryRouter>
<ToastProvider>
<AudioProvider>
<MemoryRouter initialEntries={['/tracks/demo-track']}>
<Routes>
<Route path="/tracks/:id" element={<div className="bg-kodo-background min-h-screen"><Story /></div>} />
</Routes>
</MemoryRouter>
</AudioProvider>
</ToastProvider>
</QueryClientProvider>
),
],

View file

@ -1,14 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react';
import { MarketplaceHome } from './MarketplaceHome';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
const createMockQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: Infinity },
mutations: { retry: false },
},
});
import { withRouter, withQueryClient, withToast, withAudio } from '../../stories/decorators';
const meta: Meta<typeof MarketplaceHome> = {
title: 'Pages/Marketplace/MarketplaceHome',
@ -16,14 +8,14 @@ const meta: Meta<typeof MarketplaceHome> = {
parameters: { layout: 'fullscreen' },
tags: ['autodocs'],
decorators: [
withRouter,
withQueryClient,
withToast,
withAudio,
(Story) => (
<QueryClientProvider client={createMockQueryClient()}>
<BrowserRouter>
<div className="bg-kodo-background min-h-screen">
<Story />
</div>
</BrowserRouter>
</QueryClientProvider>
<div className="bg-kodo-background min-h-screen">
<Story />
</div>
),
],
};

View file

@ -0,0 +1,72 @@
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/feedback/ToastProvider';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useCartStore } from '../stores/cartStore';
import { AudioProvider } from '../context/AudioContext';
// === HELPER UTILS ===
/**
* Wraps the story in an AudioProvider.
* Useful for player components and views requiring audio context.
*/
export const withAudio = (Story: React.ComponentType) => (
<AudioProvider>
<Story />
</AudioProvider>
);
// Create a new QueryClient for each story to ensure isolation
const createTestQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: Infinity },
mutations: { retry: false },
},
});
// === DECORATORS ===
/**
* Wraps the story in a MemoryRouter.
* Useful for components using Link, useNavigate, useLocation, etc.
*/
export const withRouter = (Story: React.ComponentType) => (
<MemoryRouter initialEntries={['/']}>
<Story />
</MemoryRouter>
);
/**
* Wraps the story in a ToastProvider.
* Useful for components triggering notifications (addToast).
*/
export const withToast = (Story: React.ComponentType) => (
<ToastProvider>
<div className="relative min-h-[300px] w-full">
<Story />
</div>
</ToastProvider>
);
/**
* Wraps the story in a QueryClientProvider.
* Useful for Smart Components fetching data.
*/
export const withQueryClient = (Story: React.ComponentType) => (
<QueryClientProvider client={createTestQueryClient()}>
<Story />
</QueryClientProvider>
);
/**
* Helper to mock Cart Store state.
* Usage: decorators: [withCartState([{ product: ..., quantity: 1 }])]
*/
export const withCartState = (initialItems: any[]) => (Story: React.ComponentType) => {
React.useEffect(() => {
useCartStore.setState({ items: initialItems });
return () => useCartStore.setState({ items: [] });
}, [initialItems]);
return <Story />;
};

View file

@ -32,7 +32,7 @@ let isResolved = false;
toastModulePromise.then((mod) => {
toastModule = mod;
isResolved = true;
}).catch((err) => {
}).catch(() => {
isResolved = true;
// Ignorer les erreurs de chargement
});
@ -48,43 +48,43 @@ function getToastModuleSync() {
// Le module a échoué à charger, retourner un stub
logger.error('Toast module failed to load');
return {
success: () => {},
error: () => {},
loading: () => {},
custom: () => {},
dismiss: () => {},
remove: () => {},
success: () => { },
error: () => { },
loading: () => { },
custom: () => { },
dismiss: () => { },
remove: () => { },
promise: () => Promise.resolve(),
} as any;
}
if (toastModule) {
return toastModule.default;
}
// Si le module n'est pas encore chargé, retourner un stub temporaire
// qui sera remplacé une fois le module chargé
return {
success: (...args: any[]) => {
toastModulePromise.then((mod) => mod.default.success(...args));
toastModulePromise.then((mod) => (mod.default.success as any)(...args));
},
error: (...args: any[]) => {
toastModulePromise.then((mod) => mod.default.error(...args));
toastModulePromise.then((mod) => (mod.default.error as any)(...args));
},
loading: (...args: any[]) => {
toastModulePromise.then((mod) => mod.default.loading(...args));
toastModulePromise.then((mod) => (mod.default.loading as any)(...args));
},
custom: (...args: any[]) => {
toastModulePromise.then((mod) => mod.default.custom(...args));
toastModulePromise.then((mod) => (mod.default.custom as any)(...args));
},
dismiss: (...args: any[]) => {
toastModulePromise.then((mod) => mod.default.dismiss(...args));
toastModulePromise.then((mod) => (mod.default.dismiss as any)(...args));
},
remove: (...args: any[]) => {
toastModulePromise.then((mod) => mod.default.remove(...args));
toastModulePromise.then((mod) => (mod.default.remove as any)(...args));
},
promise: (...args: any[]) => {
return toastModulePromise.then((mod) => mod.default.promise(...args));
return toastModulePromise.then((mod) => (mod.default.promise as any)(...args));
},
} as any;
}
@ -93,7 +93,7 @@ function getToastModuleSync() {
const toast = new Proxy({} as typeof import('react-hot-toast').default, {
get(_target, prop) {
const toastFn = getToastModuleSync() as any;
if (prop in toastFn) {
const method = toastFn[prop];
if (typeof method === 'function') {
@ -101,7 +101,7 @@ const toast = new Proxy({} as typeof import('react-hot-toast').default, {
}
return method;
}
return undefined;
},
apply(_target, _thisArg, args) {
@ -110,7 +110,7 @@ const toast = new Proxy({} as typeof import('react-hot-toast').default, {
return toastFn(...args);
}
// Si ce n'est pas une fonction, essayer d'appeler via le module
return toastModulePromise.then((mod) => mod.default(...args));
return toastModulePromise.then((mod) => (mod.default as any)(...args));
},
}) as typeof import('react-hot-toast').default;