feat(web): update all features, stories, e2e tests, and auth interceptor

Update auth, playlists, tracks, search, profile, dashboard, player,
settings, and social features. Add e2e audit specs for all major pages.
Update ESLint config, vitest config, and route configuration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
senke 2026-03-31 19:16:36 +02:00
parent dfeff836ce
commit 9a4c0d2af4
163 changed files with 8642 additions and 1475 deletions

View file

@ -1,3 +1,6 @@
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import storybook from "eslint-plugin-storybook";
// eslint-plugin-storybook optional: install if needed for Storybook-specific lint rules // eslint-plugin-storybook optional: install if needed for Storybook-specific lint rules
// import storybook from "eslint-plugin-storybook"; // import storybook from "eslint-plugin-storybook";
@ -293,4 +296,4 @@ export default [js.configs.recommended, {
'*.config.cjs', '*.config.cjs',
'**/ui.backup/**', '**/ui.backup/**',
], ],
}]; }, ...storybook.configs["flat/recommended"]];

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test'; import { fn } from 'storybook/test';
import { UserTableRow } from './UserTableRow'; import { UserTableRow } from './UserTableRow';
import { User } from '@/types'; import { User } from '@/types';

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test'; import { fn } from 'storybook/test';
import { BanUserModal } from './BanUserModal'; import { BanUserModal } from './BanUserModal';
/** /**

View file

@ -1,6 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { CartItem } from './CartItem'; import { CartItem } from './CartItem';
import { fn } from '@storybook/test'; import { fn } from 'storybook/test';
import type { CartItem as CartItemType } from '@/stores/cartStore'; import type { CartItem as CartItemType } from '@/stores/cartStore';
const MOCK_ITEM = { const MOCK_ITEM = {

View file

@ -1,6 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { CreateAPIKeyModal } from './CreateAPIKeyModal'; import { CreateAPIKeyModal } from './CreateAPIKeyModal';
import { fn } from '@storybook/test'; import { fn } from 'storybook/test';
const meta: Meta<typeof CreateAPIKeyModal> = { const meta: Meta<typeof CreateAPIKeyModal> = {
title: 'Components/Features/Developer/Modals/CreateAPIKeyModal', title: 'Components/Features/Developer/Modals/CreateAPIKeyModal',

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test'; import { fn } from 'storybook/test';
import { EquipmentCard } from './EquipmentCard'; import { EquipmentCard } from './EquipmentCard';
import { GearItem } from '../../types'; import { GearItem } from '../../types';

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test'; import { fn } from 'storybook/test';
import { InventoryView } from './InventoryView'; import { InventoryView } from './InventoryView';
import { ToastProvider } from '@/components/feedback/ToastProvider'; import { ToastProvider } from '@/components/feedback/ToastProvider';

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test'; import { fn } from 'storybook/test';
import { LiveStreamDetailView } from './LiveStreamDetailView'; import { LiveStreamDetailView } from './LiveStreamDetailView';
import { ToastProvider } from '@/components/feedback/ToastProvider'; import { ToastProvider } from '@/components/feedback/ToastProvider';

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test'; import { fn } from 'storybook/test';
import { TipStreamerModal } from './TipStreamerModal'; import { TipStreamerModal } from './TipStreamerModal';
import { ToastProvider } from '@/components/feedback/ToastProvider'; import { ToastProvider } from '@/components/feedback/ToastProvider';

View file

@ -1,7 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { CreatorModal } from './CreatorModal'; import { CreatorModal } from './CreatorModal';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { useArgs } from '@storybook/preview-api'; import { useArgs } from 'storybook/preview-api';
const meta: Meta = { const meta: Meta = {
title: 'Components/Features/Modals/CreatorModal', title: 'Components/Features/Modals/CreatorModal',

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test'; import { fn } from 'storybook/test';
import { Search } from './Search'; import { Search } from './Search';
const mockResults = [ const mockResults = [

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test'; import { fn } from 'storybook/test';
import { SearchBar } from './SearchBar'; import { SearchBar } from './SearchBar';
const meta = { const meta = {

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test'; import { fn } from 'storybook/test';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import { SellerDashboardView } from './SellerDashboardView'; import { SellerDashboardView } from './SellerDashboardView';
import { ToastProvider } from '@/components/feedback/ToastProvider'; import { ToastProvider } from '@/components/feedback/ToastProvider';

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test'; import { fn } from 'storybook/test';
import { FlashSaleModal } from './FlashSaleModal'; import { FlashSaleModal } from './FlashSaleModal';
import { ToastProvider } from '@/components/feedback/ToastProvider'; import { ToastProvider } from '@/components/feedback/ToastProvider';
import { Product } from '../../../types'; import { Product } from '../../../types';

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test'; import { fn } from 'storybook/test';
import { ChangeEmailModal } from './ChangeEmailModal'; import { ChangeEmailModal } from './ChangeEmailModal';
/** /**

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test'; import { fn } from 'storybook/test';
import { ChangeUsernameModal } from './ChangeUsernameModal'; import { ChangeUsernameModal } from './ChangeUsernameModal';
/** /**

View file

@ -1,6 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test'; import { fn } from 'storybook/test';
import { within, userEvent } from '@storybook/test'; import { within, userEvent } from 'storybook/test';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import { TwoFactorSetup, TwoFactorSetupSkeleton } from './TwoFactorSetup'; import { TwoFactorSetup, TwoFactorSetupSkeleton } from './TwoFactorSetup';

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test'; import { fn } from 'storybook/test';
import { CommentItem } from './CommentItem'; import { CommentItem } from './CommentItem';
const mockComment = { const mockComment = {

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test'; import { fn } from 'storybook/test';
import { CreatePostModal } from './CreatePostModal'; import { CreatePostModal } from './CreatePostModal';
const meta: Meta<typeof CreatePostModal> = { const meta: Meta<typeof CreatePostModal> = {

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test'; import { fn } from 'storybook/test';
import { CreateGroupModal } from './CreateGroupModal'; import { CreateGroupModal } from './CreateGroupModal';
import { ToastProvider } from '@/components/feedback/ToastProvider'; import { ToastProvider } from '@/components/feedback/ToastProvider';

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test'; import { fn } from 'storybook/test';
import { GroupsView } from './GroupsView'; import { GroupsView } from './GroupsView';
import { ToastProvider } from '@/components/feedback/ToastProvider'; import { ToastProvider } from '@/components/feedback/ToastProvider';

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test'; import { fn } from 'storybook/test';
import { BulkUploadModal } from './BulkUploadModal'; import { BulkUploadModal } from './BulkUploadModal';
const meta: Meta<typeof BulkUploadModal> = { const meta: Meta<typeof BulkUploadModal> = {

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test'; import { fn } from 'storybook/test';
import { CoverArtUploadModal } from './CoverArtUploadModal'; import { CoverArtUploadModal } from './CoverArtUploadModal';
const meta: Meta<typeof CoverArtUploadModal> = { const meta: Meta<typeof CoverArtUploadModal> = {

View file

@ -1,6 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { LyricsEditorModal } from './LyricsEditorModal'; import { LyricsEditorModal } from './LyricsEditorModal';
import { fn } from '@storybook/test'; import { fn } from 'storybook/test';
const meta: Meta<typeof LyricsEditorModal> = { const meta: Meta<typeof LyricsEditorModal> = {
title: 'Components/Features/Upload/Metadata/LyricsEditorModal', title: 'Components/Features/Upload/Metadata/LyricsEditorModal',

View file

@ -30,14 +30,7 @@ export function AuthButton({
aria-disabled={disabled || loading ? 'true' : 'false'} aria-disabled={disabled || loading ? 'true' : 'false'}
{...props} {...props}
> >
{loading ? ( {children}
<>
<span className="sr-only">Chargement en cours</span>
<span aria-hidden="true">Chargement...</span>
</>
) : (
children
)}
</button> </button>
); );
} }

View file

@ -1,6 +1,7 @@
import React, { useId, useState } from 'react'; import React, { useId, useState } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Eye, EyeOff } from 'lucide-react'; import { Eye, EyeOff } from 'lucide-react';
import { useTranslation } from '@/hooks/useTranslation';
interface AuthInputProps extends React.InputHTMLAttributes<HTMLInputElement> { interface AuthInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
error?: string; error?: string;
@ -17,8 +18,7 @@ export function AuthInput({
type, type,
...props ...props
}: AuthInputProps) { }: AuthInputProps) {
// CRITIQUE FIX #5: Utiliser useId() de React pour générer un ID stable const { t } = useTranslation();
// qui ne change pas entre les renders, contrairement à Math.random()
const generatedId = useId(); const generatedId = useId();
const inputId = id || generatedId; const inputId = id || generatedId;
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
@ -76,8 +76,7 @@ export function AuthInput({
type="button" type="button"
onClick={() => setShowPassword(!showPassword)} onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-opacity transition-colors duration-[var(--duration-fast)]" className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-opacity transition-colors duration-[var(--duration-fast)]"
aria-label={showPassword ? 'Hide password' : 'Show password'} aria-label={showPassword ? t('common.hidePassword') : t('common.showPassword')}
tabIndex={-1}
> >
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />} {showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button> </button>

View file

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from '@/hooks/useTranslation';
import { Card } from '@/components/ui/card'; import { Card } from '@/components/ui/card';
@ -19,14 +20,15 @@ export function AuthLayout({
footerLinks, footerLinks,
className, className,
}: AuthLayoutProps) { }: AuthLayoutProps) {
const { t } = useTranslation();
return ( return (
<div <main
id="main-content"
className={cn( className={cn(
'min-h-screen flex items-center justify-center bg-background py-12 px-4 sm:px-6 lg:px-8 relative overflow-hidden', 'min-h-screen flex items-center justify-center bg-background py-12 px-4 sm:px-6 lg:px-8 relative overflow-hidden',
className, className,
)} )}
role="main" aria-label={t('auth.layout.pageLabel')}
aria-label="Page d'authentification"
> >
{/* Background — Sumi-e ink wash atmosphere */} {/* Background — Sumi-e ink wash atmosphere */}
<div className="fixed inset-0 bg-[var(--sumi-bg-void)]"> <div className="fixed inset-0 bg-[var(--sumi-bg-void)]">
@ -95,7 +97,7 @@ export function AuthLayout({
{footerLinks && footerLinks.length > 0 && ( {footerLinks && footerLinks.length > 0 && (
<nav <nav
className="text-center space-x-6" className="text-center space-x-6"
aria-label="Navigation d'authentification" aria-label={t('auth.layout.navLabel')}
> >
{footerLinks.map((link) => ( {footerLinks.map((link) => (
<Link <Link
@ -110,6 +112,6 @@ export function AuthLayout({
</nav> </nav>
)} )}
</div> </div>
</div> </main>
); );
} }

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { within, userEvent } from '@storybook/test'; import { within, userEvent } from 'storybook/test';
import { ForgotPasswordForm } from './ForgotPasswordForm'; import { ForgotPasswordForm } from './ForgotPasswordForm';
/** /**

View file

@ -1,5 +1,6 @@
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Github } from 'lucide-react'; import { Github } from 'lucide-react';
import { useTranslation } from '@/hooks/useTranslation';
interface OAuthButtonProps { interface OAuthButtonProps {
provider: 'google' | 'github' | 'discord' | 'spotify'; provider: 'google' | 'github' | 'discord' | 'spotify';
@ -58,13 +59,14 @@ const providerConfig = {
} as const; } as const;
export function OAuthButton({ provider, onClick, className }: OAuthButtonProps) { export function OAuthButton({ provider, onClick, className }: OAuthButtonProps) {
const { t } = useTranslation();
const config = providerConfig[provider]; const config = providerConfig[provider];
return ( return (
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
aria-label={config.ariaLabel} aria-label={t('auth.login.oauthProvider', { provider: config.label })}
className={cn( className={cn(
'w-full flex items-center justify-center gap-3 px-4 py-2.5 rounded-lg', 'w-full flex items-center justify-center gap-3 px-4 py-2.5 rounded-lg',
'bg-muted/50 border border-border text-foreground', 'bg-muted/50 border border-border text-foreground',

View file

@ -1,5 +1,7 @@
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Check, X } from 'lucide-react'; import { Check, X } from 'lucide-react';
import { useTranslation } from '@/hooks/useTranslation';
import type { TFunction } from 'i18next';
interface PasswordStrengthIndicatorProps { interface PasswordStrengthIndicatorProps {
password: string; password: string;
@ -10,42 +12,42 @@ interface StrengthResult {
label: string; label: string;
color: string; color: string;
textColor: string; textColor: string;
requirements: Array<{ text: string; met: boolean }>; requirements: Array<{ key: string; text: string; met: boolean }>;
} }
function getStrength(pwd: string): StrengthResult { function getStrength(pwd: string, t: TFunction): StrengthResult {
const requirements: Array<{ text: string; met: boolean }> = []; const requirements: Array<{ key: string; text: string; met: boolean }> = [];
let strength = 0; let strength = 0;
// Minimum 12 caractères (requis)
const hasLength = pwd.length >= 12; const hasLength = pwd.length >= 12;
if (hasLength) strength++; if (hasLength) strength++;
requirements.push({ requirements.push({
text: `Au moins 12 caractères (${pwd.length}/12)`, key: 'length',
text: t('auth.register.passwordStrength.reqLength', { current: pwd.length }),
met: hasLength, met: hasLength,
}); });
// Majuscule et minuscule
const hasCase = /[a-z]/.test(pwd) && /[A-Z]/.test(pwd); const hasCase = /[a-z]/.test(pwd) && /[A-Z]/.test(pwd);
if (hasCase) strength++; if (hasCase) strength++;
requirements.push({ requirements.push({
text: 'Majuscule et minuscule', key: 'case',
text: t('auth.register.passwordStrength.reqCase'),
met: hasCase, met: hasCase,
}); });
// Chiffre
const hasDigit = /\d/.test(pwd); const hasDigit = /\d/.test(pwd);
if (hasDigit) strength++; if (hasDigit) strength++;
requirements.push({ requirements.push({
text: 'Un chiffre', key: 'digit',
text: t('auth.register.passwordStrength.reqDigit'),
met: hasDigit, met: hasDigit,
}); });
// Caractère spécial
const hasSpecial = /[^a-zA-Z\d]/.test(pwd); const hasSpecial = /[^a-zA-Z\d]/.test(pwd);
if (hasSpecial) strength++; if (hasSpecial) strength++;
requirements.push({ requirements.push({
text: 'Un caractère spécial (!@#$%^&*...)', key: 'special',
text: t('auth.register.passwordStrength.reqSpecial'),
met: hasSpecial, met: hasSpecial,
}); });
@ -53,19 +55,19 @@ function getStrength(pwd: string): StrengthResult {
let color: string; let color: string;
let textColor: string; let textColor: string;
if (strength <= 1) { if (strength <= 1) {
label = 'Weak'; label = t('auth.register.passwordStrength.weak');
color = 'bg-destructive'; color = 'bg-destructive';
textColor = 'text-destructive'; textColor = 'text-destructive';
} else if (strength === 2) { } else if (strength === 2) {
label = 'Fair'; label = t('auth.register.passwordStrength.fair');
color = 'bg-warning'; color = 'bg-warning';
textColor = 'text-warning'; textColor = 'text-warning';
} else if (strength === 3) { } else if (strength === 3) {
label = 'Good'; label = t('auth.register.passwordStrength.good');
color = 'bg-warning'; color = 'bg-warning';
textColor = 'text-warning'; textColor = 'text-warning';
} else { } else {
label = 'Strong'; label = t('auth.register.passwordStrength.strong');
color = 'bg-success'; color = 'bg-success';
textColor = 'text-success'; textColor = 'text-success';
} }
@ -76,9 +78,11 @@ function getStrength(pwd: string): StrengthResult {
export function PasswordStrengthIndicator({ export function PasswordStrengthIndicator({
password, password,
}: PasswordStrengthIndicatorProps) { }: PasswordStrengthIndicatorProps) {
const { t } = useTranslation();
if (!password) return null; if (!password) return null;
const { level, label, color, textColor, requirements } = getStrength(password); const { level, label, color, textColor, requirements } = getStrength(password, t);
return ( return (
<div <div
@ -98,7 +102,7 @@ export function PasswordStrengthIndicator({
aria-valuenow={segment === 1 ? level : undefined} aria-valuenow={segment === 1 ? level : undefined}
aria-valuemin={segment === 1 ? 0 : undefined} aria-valuemin={segment === 1 ? 0 : undefined}
aria-valuemax={segment === 1 ? 4 : undefined} aria-valuemax={segment === 1 ? 4 : undefined}
aria-label={segment === 1 ? `Force du mot de passe: ${label}` : undefined} aria-label={segment === 1 ? t('auth.register.passwordStrength.label', { level: label }) : undefined}
> >
<div <div
className={cn( className={cn(
@ -118,7 +122,7 @@ export function PasswordStrengthIndicator({
<ul className="space-y-1"> <ul className="space-y-1">
{requirements.map((req) => ( {requirements.map((req) => (
<li <li
key={req.text} key={req.key}
className={cn( className={cn(
'flex items-center gap-2 text-xs transition-colors duration-[var(--duration-fast)]', 'flex items-center gap-2 text-xs transition-colors duration-[var(--duration-fast)]',
req.met ? 'text-success' : 'text-muted-foreground', req.met ? 'text-success' : 'text-muted-foreground',

View file

@ -1,6 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test'; import { fn } from 'storybook/test';
import { within, userEvent } from '@storybook/test'; import { within, userEvent } from 'storybook/test';
import { TwoFactorVerify } from './TwoFactorVerify'; import { TwoFactorVerify } from './TwoFactorVerify';
/** /**

View file

@ -12,6 +12,7 @@ import { Label } from '@/components/ui/label';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { Loader2, Shield, AlertCircle } from 'lucide-react'; import { Loader2, Shield, AlertCircle } from 'lucide-react';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/hooks/useTranslation';
import { parseApiError } from '@/utils/apiErrorHandler'; import { parseApiError } from '@/utils/apiErrorHandler';
interface TwoFactorVerifyProps { interface TwoFactorVerifyProps {
@ -22,6 +23,7 @@ interface TwoFactorVerifyProps {
} }
export function TwoFactorVerify({ onSuccess, onCancel, isSubmitting = false }: TwoFactorVerifyProps) { export function TwoFactorVerify({ onSuccess, onCancel, isSubmitting = false }: TwoFactorVerifyProps) {
const { t } = useTranslation();
const [code, setCode] = useState(''); const [code, setCode] = useState('');
const [backupCode, setBackupCode] = useState(''); const [backupCode, setBackupCode] = useState('');
const [useBackupCode, setUseBackupCode] = useState(false); const [useBackupCode, setUseBackupCode] = useState(false);
@ -32,7 +34,7 @@ export function TwoFactorVerify({ onSuccess, onCancel, isSubmitting = false }: T
const handleVerify = async () => { const handleVerify = async () => {
if (!code && !backupCode) { if (!code && !backupCode) {
setError('Please enter a verification code'); setError(t('auth.twoFactor.enterCodeError'));
return; return;
} }
@ -62,18 +64,17 @@ export function TwoFactorVerify({ onSuccess, onCancel, isSubmitting = false }: T
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5 text-muted-foreground" /> <Shield className="h-5 w-5 text-muted-foreground" />
Two-Factor Authentication {t('auth.twoFactor.title')}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Enter the code from your authenticator app {t('auth.twoFactor.subtitle')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<Alert> <Alert>
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<AlertDescription> <AlertDescription>
Enter the 6-digit code from your authenticator app to continue {t('auth.twoFactor.enterCode')}
signing in.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
@ -87,7 +88,7 @@ export function TwoFactorVerify({ onSuccess, onCancel, isSubmitting = false }: T
{!useBackupCode ? ( {!useBackupCode ? (
<> <>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="2fa-code">Verification Code</Label> <Label htmlFor="2fa-code">{t('auth.twoFactor.verificationCode')}</Label>
<Input <Input
id="2fa-code" id="2fa-code"
type="text" type="text"
@ -103,23 +104,23 @@ export function TwoFactorVerify({ onSuccess, onCancel, isSubmitting = false }: T
/> />
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Lost access?{' '} {t('auth.twoFactor.lostAccess')}{' '}
<button <button
onClick={() => setUseBackupCode(true)} onClick={() => setUseBackupCode(true)}
className="text-primary hover:underline" className="text-primary hover:underline"
> >
Use a backup code {t('auth.twoFactor.useBackupCode')}
</button> </button>
</p> </p>
</> </>
) : ( ) : (
<> <>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="backup-code">Backup Code</Label> <Label htmlFor="backup-code">{t('auth.twoFactor.backupCode')}</Label>
<Input <Input
id="backup-code" id="backup-code"
type="text" type="text"
placeholder="Enter backup code" placeholder={t('auth.twoFactor.backupCode')}
value={backupCode} value={backupCode}
onChange={(e) => { onChange={(e) => {
setBackupCode(e.target.value); setBackupCode(e.target.value);
@ -132,7 +133,7 @@ export function TwoFactorVerify({ onSuccess, onCancel, isSubmitting = false }: T
onClick={() => setUseBackupCode(false)} onClick={() => setUseBackupCode(false)}
className="text-primary hover:underline" className="text-primary hover:underline"
> >
Use authenticator code instead {t('auth.twoFactor.useAuthenticator')}
</button> </button>
</p> </p>
</> </>
@ -140,7 +141,7 @@ export function TwoFactorVerify({ onSuccess, onCancel, isSubmitting = false }: T
<div className="flex gap-2"> <div className="flex gap-2">
<Button onClick={onCancel} variant="outline" className="flex-1" disabled={busy}> <Button onClick={onCancel} variant="outline" className="flex-1" disabled={busy}>
Cancel {t('auth.twoFactor.cancel')}
</Button> </Button>
<Button <Button
onClick={handleVerify} onClick={handleVerify}
@ -150,10 +151,10 @@ export function TwoFactorVerify({ onSuccess, onCancel, isSubmitting = false }: T
{busy ? ( {busy ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
Verifying... {t('auth.twoFactor.verifying')}
</> </>
) : ( ) : (
'Verify' t('auth.twoFactor.verify')
)} )}
</Button> </Button>
</div> </div>

View file

@ -1,11 +1,13 @@
import { Navigate } from 'react-router-dom'; import { Navigate } from 'react-router-dom';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { useTranslation } from '@/hooks/useTranslation';
import { AuthLayout } from '../AuthLayout'; import { AuthLayout } from '../AuthLayout';
import { useRegisterPage } from './useRegisterPage'; import { useRegisterPage } from './useRegisterPage';
import { RegisterPageForm } from './RegisterPageForm'; import { RegisterPageForm } from './RegisterPageForm';
import { RegisterPageVerificationNotice } from './RegisterPageVerificationNotice'; import { RegisterPageVerificationNotice } from './RegisterPageVerificationNotice';
export function RegisterPage() { export function RegisterPage() {
const { t } = useTranslation();
const { isAuthenticated } = useAuthStore(); const { isAuthenticated } = useAuthStore();
const { const {
formData, formData,
@ -20,6 +22,7 @@ export function RegisterPage() {
error, error,
resendLoading, resendLoading,
resendSuccess, resendSuccess,
resendError,
handleChange, handleChange,
handleBlur, handleBlur,
onSubmit, onSubmit,
@ -32,15 +35,16 @@ export function RegisterPage() {
return ( return (
<AuthLayout <AuthLayout
title="Inscription" title={t('auth.register.title')}
subtitle="Créez votre compte" subtitle={t('auth.register.subtitle')}
footerLinks={[{ label: 'Déjà un compte ? Se connecter', to: '/login' }]} footerLinks={[{ label: t('auth.register.footerLink'), to: '/login' }]}
> >
{showVerificationNotice ? ( {showVerificationNotice ? (
<RegisterPageVerificationNotice <RegisterPageVerificationNotice
email={formData.email} email={formData.email}
resendLoading={resendLoading} resendLoading={resendLoading}
resendSuccess={resendSuccess} resendSuccess={resendSuccess}
resendError={resendError}
onResend={handleResendVerificationEmail} onResend={handleResendVerificationEmail}
/> />
) : ( ) : (

View file

@ -1,6 +1,7 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { AlertCircle, Loader2, Check, X } from 'lucide-react'; import { AlertCircle, Loader2, Check, X } from 'lucide-react';
import { useTranslation } from '@/hooks/useTranslation';
import { AuthInput } from '../AuthInput'; import { AuthInput } from '../AuthInput';
import { AuthButton } from '../AuthButton'; import { AuthButton } from '../AuthButton';
import { PasswordStrengthIndicator } from '../PasswordStrengthIndicator'; import { PasswordStrengthIndicator } from '../PasswordStrengthIndicator';
@ -36,11 +37,13 @@ export function RegisterPageForm({
onFieldBlur, onFieldBlur,
onSubmit, onSubmit,
}: RegisterPageFormProps) { }: RegisterPageFormProps) {
const { t } = useTranslation();
return ( return (
<form <form
onSubmit={onSubmit} onSubmit={onSubmit}
className="space-y-4" className="space-y-4"
aria-label="Formulaire d'inscription" aria-label={t('auth.register.formAriaLabel')}
data-testid="register-form" data-testid="register-form"
> >
{error && ( {error && (
@ -60,7 +63,7 @@ export function RegisterPageForm({
<AuthInput <AuthInput
id="register-username" id="register-username"
type="text" type="text"
label="Nom d'utilisateur" label={t('auth.register.username')}
value={formData.username} value={formData.username}
onChange={(e) => onFieldChange('username', e.target.value)} onChange={(e) => onFieldChange('username', e.target.value)}
onBlur={() => onFieldBlur('username')} onBlur={() => onFieldBlur('username')}
@ -73,17 +76,17 @@ export function RegisterPageForm({
{checkingUsername ? ( {checkingUsername ? (
<p className="text-xs text-muted-foreground flex items-center gap-1.5" role="status"> <p className="text-xs text-muted-foreground flex items-center gap-1.5" role="status">
<span className="h-3 w-3 border-2 border-muted-foreground border-t-transparent rounded-full animate-spin" /> <span className="h-3 w-3 border-2 border-muted-foreground border-t-transparent rounded-full animate-spin" />
<span>Vérification...</span> <span>{t('auth.register.usernameCheck.checking')}</span>
</p> </p>
) : usernameAvailable === true ? ( ) : usernameAvailable === true ? (
<p className="text-xs text-success flex items-center gap-1.5" role="status"> <p className="text-xs text-success flex items-center gap-1.5" role="status">
<Check className="h-3 w-3" /> <Check className="h-3 w-3" />
<span>Ce nom d'utilisateur est disponible</span> <span>{t('auth.register.usernameCheck.available')}</span>
</p> </p>
) : usernameAvailable === false ? ( ) : usernameAvailable === false ? (
<p className="text-xs text-destructive flex items-center gap-1.5" role="alert"> <p className="text-xs text-destructive flex items-center gap-1.5" role="alert">
<X className="h-3 w-3" /> <X className="h-3 w-3" />
<span>Ce nom d'utilisateur est déjà pris</span> <span>{t('auth.register.usernameCheck.unavailable')}</span>
</p> </p>
) : null} ) : null}
</div> </div>
@ -94,7 +97,7 @@ export function RegisterPageForm({
<AuthInput <AuthInput
id="register-email" id="register-email"
type="email" type="email"
label="Email" label={t('auth.register.email')}
value={formData.email} value={formData.email}
onChange={(e) => onFieldChange('email', e.target.value)} onChange={(e) => onFieldChange('email', e.target.value)}
onBlur={() => onFieldBlur('email')} onBlur={() => onFieldBlur('email')}
@ -108,7 +111,7 @@ export function RegisterPageForm({
<AuthInput <AuthInput
id="register-password" id="register-password"
type="password" type="password"
label="Mot de passe" label={t('auth.register.password')}
value={formData.password} value={formData.password}
onChange={(e) => onFieldChange('password', e.target.value)} onChange={(e) => onFieldChange('password', e.target.value)}
onBlur={() => onFieldBlur('password')} onBlur={() => onFieldBlur('password')}
@ -124,7 +127,7 @@ export function RegisterPageForm({
<AuthInput <AuthInput
id="register-password_confirm" id="register-password_confirm"
type="password" type="password"
label="Confirmer le mot de passe" label={t('auth.register.confirmPassword')}
value={formData.password_confirm} value={formData.password_confirm}
onChange={(e) => onFieldChange('password_confirm', e.target.value)} onChange={(e) => onFieldChange('password_confirm', e.target.value)}
onBlur={() => onFieldBlur('password_confirm')} onBlur={() => onFieldBlur('password_confirm')}
@ -147,30 +150,30 @@ export function RegisterPageForm({
}} }}
required required
aria-invalid={errors.terms ? 'true' : 'false'} aria-invalid={errors.terms ? 'true' : 'false'}
aria-describedby={errors.terms ? 'terms-error' : 'terms-description'} aria-describedby={errors.terms ? 'terms-description terms-error' : 'terms-description'}
/> />
</div> </div>
<label htmlFor="register-terms" className="text-sm text-muted-foreground leading-relaxed cursor-pointer inline-flex flex-wrap gap-x-1"> <label htmlFor="register-terms" className="text-sm text-muted-foreground leading-relaxed cursor-pointer inline-flex flex-wrap gap-x-1">
<span>J'accepte les</span> <span>{t('auth.register.terms.accept')}</span>
<Link <Link
to="/terms" to="/terms"
className="text-foreground hover:underline underline-offset-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded" className="text-foreground hover:underline underline-offset-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded"
aria-label="Lire les conditions d'utilisation" aria-label={t('auth.register.terms.termsAriaLabel')}
> >
conditions d'utilisation {t('auth.register.terms.termsOfService')}
</Link> </Link>
<span>et la</span> <span>{t('auth.register.terms.and')}</span>
<Link <Link
to="/privacy" to="/privacy"
className="text-foreground hover:underline underline-offset-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded" className="text-foreground hover:underline underline-offset-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded"
aria-label="Lire la politique de confidentialité" aria-label={t('auth.register.terms.privacyAriaLabel')}
> >
politique de confidentialité {t('auth.register.terms.privacyPolicy')}
</Link> </Link>
</label> </label>
</div> </div>
<p id="terms-description" className="sr-only"> <p id="terms-description" className="sr-only">
Vous devez accepter les conditions d'utilisation et la politique de confidentialité pour créer un compte {t('auth.register.terms.description')}
</p> </p>
{errors.terms && ( {errors.terms && (
<p id="terms-error" className="text-sm text-destructive animate-shake" role="alert"> <p id="terms-error" className="text-sm text-destructive animate-shake" role="alert">
@ -187,10 +190,10 @@ export function RegisterPageForm({
{loading ? ( {loading ? (
<> <>
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> <Loader2 className="w-4 h-4 mr-2 animate-spin" />
Inscription en cours... {t('auth.register.loadingText')}
</> </>
) : ( ) : (
"S'inscrire" t('auth.register.registerButton')
)} )}
</AuthButton> </AuthButton>
</form> </form>

View file

@ -1,10 +1,12 @@
import { AuthButton } from '../AuthButton'; import { AuthButton } from '../AuthButton';
import { Loader2, Mail, CheckCircle2 } from 'lucide-react'; import { Loader2, Mail, CheckCircle2, AlertCircle } from 'lucide-react';
import { useTranslation } from '@/hooks/useTranslation';
interface RegisterPageVerificationNoticeProps { interface RegisterPageVerificationNoticeProps {
email: string; email: string;
resendLoading: boolean; resendLoading: boolean;
resendSuccess: boolean; resendSuccess: boolean;
resendError: string | null;
onResend: () => void; onResend: () => void;
} }
@ -12,8 +14,11 @@ export function RegisterPageVerificationNotice({
email, email,
resendLoading, resendLoading,
resendSuccess, resendSuccess,
resendError,
onResend, onResend,
}: RegisterPageVerificationNoticeProps) { }: RegisterPageVerificationNoticeProps) {
const { t } = useTranslation();
return ( return (
<div className="text-center space-y-5 animate-fade-in py-4" role="status" aria-live="polite"> <div className="text-center space-y-5 animate-fade-in py-4" role="status" aria-live="polite">
{/* Success icon */} {/* Success icon */}
@ -24,15 +29,15 @@ export function RegisterPageVerificationNotice({
</div> </div>
<div> <div>
<p className="text-lg font-semibold text-foreground">Inscription réussie !</p> <p className="text-lg font-semibold text-foreground">{t('auth.register.verification.title')}</p>
<p className="text-sm text-muted-foreground mt-2"> <p className="text-sm text-muted-foreground mt-2">
Un email de vérification a é envoyé à{' '} {t('auth.register.verification.emailSent')}{' '}
<span className="font-medium text-foreground">{email}</span> <span className="font-medium text-foreground">{email}</span>
</p> </p>
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Veuillez vérifier votre boîte mail et cliquer sur le lien de vérification. {t('auth.register.verification.checkInbox')}
</p> </p>
{resendSuccess && ( {resendSuccess && (
@ -42,7 +47,18 @@ export function RegisterPageVerificationNotice({
aria-live="polite" aria-live="polite"
> >
<CheckCircle2 className="h-4 w-4" /> <CheckCircle2 className="h-4 w-4" />
<span>Email de vérification renvoyé avec succès !</span> <span>{t('auth.register.verification.resendSuccess')}</span>
</div>
)}
{resendError && (
<div
className="flex items-center justify-center gap-2 text-sm text-destructive animate-fade-in"
role="alert"
aria-live="assertive"
>
<AlertCircle className="h-4 w-4" />
<span>{resendError}</span>
</div> </div>
)} )}
@ -51,15 +67,15 @@ export function RegisterPageVerificationNotice({
variant="secondary" variant="secondary"
onClick={onResend} onClick={onResend}
disabled={resendLoading} disabled={resendLoading}
aria-label="Renvoyer l'email de vérification" aria-label={t('auth.register.verification.resendButton')}
> >
{resendLoading ? ( {resendLoading ? (
<> <>
<Loader2 className="w-4 h-4 mr-2 animate-spin inline" /> <Loader2 className="w-4 h-4 mr-2 animate-spin inline" />
Envoi en cours... {t('auth.register.verification.resendLoading')}
</> </>
) : ( ) : (
"Renvoyer l'email de vérification" t('auth.register.verification.resendButton')
)} )}
</AuthButton> </AuthButton>
</div> </div>

View file

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useRegister } from '../../hooks/useRegister'; import { useRegister } from '../../hooks/useRegister';
import { useUsernameAvailability } from '../../hooks/useUsernameAvailability'; import { useUsernameAvailability } from '../../hooks/useUsernameAvailability';
import { useTranslation } from '@/hooks/useTranslation';
import { authApi } from '@/services/api/auth'; import { authApi } from '@/services/api/auth';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
import type { RegisterFormData } from '../../types'; import type { RegisterFormData } from '../../types';
@ -15,6 +16,7 @@ const initialFormData: RegisterFormData = {
}; };
export function useRegisterPage() { export function useRegisterPage() {
const { t } = useTranslation();
const { const {
mutate: handleRegister, mutate: handleRegister,
isPending: loading, isPending: loading,
@ -30,36 +32,52 @@ export function useRegisterPage() {
const [showVerificationNotice, setShowVerificationNotice] = useState(false); const [showVerificationNotice, setShowVerificationNotice] = useState(false);
const [resendLoading, setResendLoading] = useState(false); const [resendLoading, setResendLoading] = useState(false);
const [resendSuccess, setResendSuccess] = useState(false); const [resendSuccess, setResendSuccess] = useState(false);
const [resendError, setResendError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (success) setShowVerificationNotice(true); if (success) {
}, [success]); setShowVerificationNotice(true);
// Store email for the verify-email page's resend functionality
if (formData.email) {
localStorage.setItem('pendingVerificationEmail', formData.email);
}
}
}, [success, formData.email]);
const validate = (): boolean => { const validate = (): boolean => {
const newErrors: FormErrors = {}; const newErrors: FormErrors = {};
if (!formData.email) { if (!formData.email) {
newErrors.email = 'Email requis'; newErrors.email = t('auth.register.errors.emailRequired');
} else if (!/\S+@\S+\.\S+/.test(formData.email)) { } else if (!/\S+@\S+\.\S+/.test(formData.email)) {
newErrors.email = 'Email invalide'; newErrors.email = t('auth.register.errors.emailInvalid');
} }
if (!formData.username) { if (!formData.username) {
newErrors.username = "Nom d'utilisateur requis"; newErrors.username = t('auth.register.errors.usernameRequired');
} else if (formData.username.length < 3) { } else if (formData.username.length < 3) {
newErrors.username = "Le nom d'utilisateur doit contenir au moins 3 caractères"; newErrors.username = t('auth.register.errors.usernameTooShort');
} else if (usernameAvailable === false) { } else if (usernameAvailable === false) {
newErrors.username = "Ce nom d'utilisateur est déjà pris"; newErrors.username = t('auth.register.errors.usernameUnavailable');
} }
if (!formData.password) { if (!formData.password) {
newErrors.password = 'Mot de passe requis'; newErrors.password = t('auth.register.errors.passwordRequired');
} else if (formData.password.length < 12) { } else if (formData.password.length < 12) {
newErrors.password = 'Le mot de passe doit contenir au moins 12 caractères'; newErrors.password = t('auth.register.errors.passwordTooShort');
} else {
const pwd = formData.password;
const hasCase = /[a-z]/.test(pwd) && /[A-Z]/.test(pwd);
const hasDigit = /\d/.test(pwd);
const hasSpecial = /[^a-zA-Z\d]/.test(pwd);
if (!hasCase || !hasDigit || !hasSpecial) {
newErrors.password = t('auth.register.errors.passwordWeak');
}
} }
if (formData.password !== formData.password_confirm) { if (!formData.password_confirm) {
newErrors.password_confirm = 'Les mots de passe ne correspondent pas'; newErrors.password_confirm = t('auth.register.errors.confirmRequired');
} else if (formData.password !== formData.password_confirm) {
newErrors.password_confirm = t('auth.register.errors.passwordMismatch');
} }
if (!acceptedTerms) { if (!acceptedTerms) {
newErrors.terms = newErrors.terms = t('auth.register.errors.termsRequired');
"Vous devez accepter les conditions d'utilisation et la politique de confidentialité";
} }
setErrors(newErrors); setErrors(newErrors);
return Object.keys(newErrors).length === 0; return Object.keys(newErrors).length === 0;
@ -72,25 +90,34 @@ export function useRegisterPage() {
const handleBlur = (field: keyof RegisterFormData) => { const handleBlur = (field: keyof RegisterFormData) => {
const value = formData[field]; const value = formData[field];
let error: string | undefined; let fieldError: string | undefined;
switch (field) { switch (field) {
case 'email': case 'email':
if (!value) error = 'Email requis'; if (!value) fieldError = t('auth.register.errors.emailRequired');
else if (!/\S+@\S+\.\S+/.test(value)) error = 'Email invalide'; else if (!/\S+@\S+\.\S+/.test(value)) fieldError = t('auth.register.errors.emailInvalid');
break; break;
case 'username': case 'username':
if (!value) error = "Nom d'utilisateur requis"; if (!value) fieldError = t('auth.register.errors.usernameRequired');
else if (value.length < 3) error = "Le nom d'utilisateur doit contenir au moins 3 caractères"; else if (value.length < 3) fieldError = t('auth.register.errors.usernameTooShort');
break; break;
case 'password': case 'password':
if (!value) error = 'Mot de passe requis'; if (!value) fieldError = t('auth.register.errors.passwordRequired');
else if (value.length < 12) error = 'Le mot de passe doit contenir au moins 12 caractères'; else if (value.length < 12) fieldError = t('auth.register.errors.passwordTooShort');
else {
const hasCase = /[a-z]/.test(value) && /[A-Z]/.test(value);
const hasDigit = /\d/.test(value);
const hasSpecial = /[^a-zA-Z\d]/.test(value);
if (!hasCase || !hasDigit || !hasSpecial) {
fieldError = t('auth.register.errors.passwordWeak');
}
}
break; break;
case 'password_confirm': case 'password_confirm':
if (formData.password !== value) error = 'Les mots de passe ne correspondent pas'; if (!value) fieldError = t('auth.register.errors.confirmRequired');
else if (formData.password !== value) fieldError = t('auth.register.errors.passwordMismatch');
break; break;
} }
setErrors((prev) => ({ ...prev, [field]: error })); setErrors((prev) => ({ ...prev, [field]: fieldError }));
}; };
const onSubmit = (e: React.FormEvent) => { const onSubmit = (e: React.FormEvent) => {
@ -102,10 +129,12 @@ export function useRegisterPage() {
try { try {
setResendLoading(true); setResendLoading(true);
setResendSuccess(false); setResendSuccess(false);
setResendError(null);
await authApi.resendVerification({ email: formData.email }); await authApi.resendVerification({ email: formData.email });
setResendSuccess(true); setResendSuccess(true);
} catch (err) { } catch (err) {
logger.error("Erreur lors du renvoi de l'email:", { error: err }); logger.error("Erreur lors du renvoi de l'email:", { error: err });
setResendError(t('auth.register.verification.resendError'));
} finally { } finally {
setResendLoading(false); setResendLoading(false);
} }
@ -124,6 +153,7 @@ export function useRegisterPage() {
error, error,
resendLoading, resendLoading,
resendSuccess, resendSuccess,
resendError,
handleChange, handleChange,
handleBlur, handleBlur,
onSubmit, onSubmit,

View file

@ -37,11 +37,17 @@ export function usePasswordReset() {
} }
}; };
const reset = () => {
setSuccess(false);
setError(null);
};
return { return {
handleRequestReset, handleRequestReset,
handleReset, handleReset,
loading, loading,
error, error,
success, success,
reset,
}; };
} }

View file

@ -1,6 +1,5 @@
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { register as registerService } from '@/services/api/auth';
import type { RegisterRequest } from '@/services/api/auth'; import type { RegisterRequest } from '@/services/api/auth';
export const useRegister = () => { export const useRegister = () => {
@ -8,12 +7,7 @@ export const useRegister = () => {
return useMutation({ return useMutation({
mutationFn: async (userData: RegisterRequest) => { mutationFn: async (userData: RegisterRequest) => {
// Appeler le service et mettre à jour le store
const response = await registerService(userData);
// Le store sera mis à jour automatiquement car registerService stocke déjà les tokens
// Mais on peut aussi appeler registerStore pour mettre à jour l'état
await registerStore(userData); await registerStore(userData);
return response;
}, },
}); });
}; };

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { within, userEvent } from '@storybook/test'; import { within, userEvent } from 'storybook/test';
import { ForgotPasswordPage } from './ForgotPasswordPage'; import { ForgotPasswordPage } from './ForgotPasswordPage';
import { withRouter, withQueryClient, withToast } from '../../../stories/decorators'; import { withRouter, withQueryClient, withToast } from '../../../stories/decorators';

View file

@ -1,13 +1,17 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Loader2 } from 'lucide-react';
import { AuthLayout } from '../components/AuthLayout'; import { AuthLayout } from '../components/AuthLayout';
import { AuthInput } from '../components/AuthInput'; import { AuthInput } from '../components/AuthInput';
import { AuthButton } from '../components/AuthButton'; import { AuthButton } from '../components/AuthButton';
import { usePasswordReset } from '../hooks/usePasswordReset'; import { usePasswordReset } from '../hooks/usePasswordReset';
import { useTranslation } from '@/hooks/useTranslation';
import { forgotPasswordSchema } from '@/schemas/validation';
import type { ForgotPasswordFormData } from '../types'; import type { ForgotPasswordFormData } from '../types';
export function ForgotPasswordPage() { export function ForgotPasswordPage() {
const { handleRequestReset, loading, error, success } = usePasswordReset(); const { t } = useTranslation();
const { handleRequestReset, loading, error, success, reset } = usePasswordReset();
const [formData, setFormData] = useState<ForgotPasswordFormData>({ const [formData, setFormData] = useState<ForgotPasswordFormData>({
email: '', email: '',
}); });
@ -15,22 +19,33 @@ export function ForgotPasswordPage() {
Partial<Record<keyof ForgotPasswordFormData, string>> Partial<Record<keyof ForgotPasswordFormData, string>>
>({}); >({});
useEffect(() => {
document.title = t('auth.forgotPassword.pageTitle');
}, [t]);
const validate = (): boolean => { const validate = (): boolean => {
const newErrors: Partial<Record<keyof ForgotPasswordFormData, string>> = {}; const result = forgotPasswordSchema.safeParse(formData);
if (!result.success) {
if (!formData.email) { const newErrors: Partial<Record<keyof ForgotPasswordFormData, string>> = {};
newErrors.email = 'Email requis'; for (const issue of result.error.issues) {
} else if (!/\S+@\S+\.\S+/.test(formData.email)) { const field = issue.path[0] as keyof ForgotPasswordFormData;
newErrors.email = 'Email invalide'; if (!newErrors[field]) {
if (field === 'email') {
newErrors[field] = !formData.email
? t('auth.forgotPassword.errors.emailRequired')
: t('auth.forgotPassword.errors.emailInvalid');
}
}
}
setErrors(newErrors);
return false;
} }
setErrors({});
setErrors(newErrors); return true;
return Object.keys(newErrors).length === 0;
}; };
const handleChange = (field: keyof ForgotPasswordFormData, value: string) => { const handleChange = (field: keyof ForgotPasswordFormData, value: string) => {
setFormData({ ...formData, [field]: value }); setFormData({ ...formData, [field]: value });
// Clear error for this field when user starts typing
if (errors[field]) { if (errors[field]) {
setErrors({ ...errors, [field]: undefined }); setErrors({ ...errors, [field]: undefined });
} }
@ -38,21 +53,20 @@ export function ForgotPasswordPage() {
const handleBlur = (field: keyof ForgotPasswordFormData) => { const handleBlur = (field: keyof ForgotPasswordFormData) => {
const value = formData[field]; const value = formData[field];
let error: string | undefined; let fieldError: string | undefined;
if (field === 'email') { if (field === 'email') {
if (!value) { if (!value) {
error = 'Email requis'; fieldError = t('auth.forgotPassword.errors.emailRequired');
} else if (!/\S+@\S+\.\S+/.test(value)) { } else {
error = 'Email invalide'; const result = forgotPasswordSchema.safeParse({ email: value });
if (!result.success) {
fieldError = t('auth.forgotPassword.errors.emailInvalid');
}
} }
} }
if (error) { setErrors((prev) => ({ ...prev, [field]: fieldError }));
setErrors({ ...errors, [field]: error });
} else {
setErrors({ ...errors, [field]: undefined });
}
}; };
const onSubmit = async (e: React.FormEvent) => { const onSubmit = async (e: React.FormEvent) => {
@ -64,9 +78,9 @@ export function ForgotPasswordPage() {
return ( return (
<AuthLayout <AuthLayout
title="Mot de passe oublié" title={success ? t('auth.forgotPassword.successTitle') : t('auth.forgotPassword.title')}
subtitle="Entrez votre email pour recevoir un lien de réinitialisation" subtitle={success ? undefined : t('auth.forgotPassword.subtitle')}
footerLinks={[{ label: 'Retour à la connexion', to: '/login' }]} footerLinks={success ? [] : [{ label: t('auth.forgotPassword.backToLogin'), to: '/login' }]}
> >
{success ? ( {success ? (
<div className="text-center space-y-4" role="status" aria-live="polite"> <div className="text-center space-y-4" role="status" aria-live="polite">
@ -75,27 +89,34 @@ export function ForgotPasswordPage() {
role="alert" role="alert"
aria-live="assertive" aria-live="assertive"
> >
<p className="font-medium">Email envoyé !</p> <p className="font-medium">{t('auth.forgotPassword.success')}</p>
<p className="text-sm mt-1"> <p className="text-sm mt-1">
Un lien de réinitialisation a é envoyé à {formData.email} {t('auth.forgotPassword.successBody')}
</p> </p>
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Veuillez vérifier votre boîte mail et cliquer sur le lien pour {t('auth.forgotPassword.checkInbox')}
réinitialiser votre mot de passe.
</p> </p>
<button
type="button"
onClick={reset}
className="text-primary hover:underline text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded"
>
{t('auth.forgotPassword.resendButton')}
</button>
<Link <Link
to="/login" to="/login"
className="text-primary hover:underline text-sm block focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded" className="text-primary hover:underline text-sm block focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded"
> >
Retour à la connexion {t('auth.forgotPassword.backToLogin')}
</Link> </Link>
</div> </div>
) : ( ) : (
<form <form
onSubmit={onSubmit} onSubmit={onSubmit}
className="space-y-4" className="space-y-4"
aria-label="Formulaire de réinitialisation de mot de passe" aria-label={t('auth.forgotPassword.formAriaLabel')}
data-testid="forgot-password-form"
> >
{error && ( {error && (
<div <div
@ -108,7 +129,7 @@ export function ForgotPasswordPage() {
)} )}
<AuthInput <AuthInput
type="email" type="email"
label="Email" label={t('auth.forgotPassword.email')}
value={formData.email} value={formData.email}
onChange={(e) => handleChange('email', e.target.value)} onChange={(e) => handleChange('email', e.target.value)}
onBlur={() => handleBlur('email')} onBlur={() => handleBlur('email')}
@ -116,8 +137,15 @@ export function ForgotPasswordPage() {
required required
autoComplete="email" autoComplete="email"
/> />
<AuthButton type="submit" loading={loading}> <AuthButton type="submit" loading={loading} data-testid="forgot-password-submit">
Envoyer le lien de réinitialisation {loading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin inline" />
{t('auth.forgotPassword.sendButton')}
</>
) : (
t('auth.forgotPassword.sendButton')
)}
</AuthButton> </AuthButton>
</form> </form>
)} )}

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { within, userEvent } from '@storybook/test'; import { within, userEvent } from 'storybook/test';
import { LoginPage } from './LoginPage'; import { LoginPage } from './LoginPage';
import { withRouter, withQueryClient } from '../../../stories/decorators'; import { withRouter, withQueryClient } from '../../../stories/decorators';

View file

@ -7,6 +7,55 @@ import { LoginPage } from './LoginPage';
import { useAuthStore } from '@/features/auth/store/authStore'; import { useAuthStore } from '@/features/auth/store/authStore';
import { useLogin } from '../hooks/useLogin'; import { useLogin } from '../hooks/useLogin';
// Mock i18n
vi.mock('@/hooks/useTranslation', () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, string>) => {
const translations: Record<string, string> = {
'auth.login.title': 'Login',
'auth.login.subtitle': 'Sign in to your Veza account',
'auth.login.email': 'Email',
'auth.login.password': 'Password',
'auth.login.rememberMe': 'Remember me',
'auth.login.forgotPassword': 'Forgot password?',
'auth.login.loginButton': 'Sign in',
'auth.login.footerLink': "Don't have an account? Sign up",
'auth.login.orContinueWith': 'or continue with',
'auth.login.oauthProvider': `Sign in with ${opts?.provider ?? ''}`,
'auth.login.errors.invalidCredentials': 'Invalid email or password',
'auth.login.errors.emailNotVerified': 'Email not verified',
'auth.login.errors.connectionError': 'Connection error. Check your internet.',
'auth.login.errors.genericError': 'An error occurred. Please try again.',
'auth.login.errors.emailRequired': 'Email is required',
'auth.login.errors.emailInvalid': 'Invalid email format',
'auth.login.errors.passwordRequired': 'Password is required',
'auth.twoFactor.title': 'Two-factor authentication',
'auth.twoFactor.subtitle': 'Enter the code from your authenticator app',
'auth.twoFactor.backToSignIn': 'Back to sign in',
'auth.twoFactor.verificationCode': 'Verification Code',
'auth.twoFactor.enterCode': 'Enter the 6-digit code from your authenticator app to continue signing in.',
'auth.twoFactor.enterCodeError': 'Please enter a verification code',
'auth.twoFactor.lostAccess': 'Lost access?',
'auth.twoFactor.useBackupCode': 'Use a backup code',
'auth.twoFactor.useAuthenticator': 'Use authenticator code instead',
'auth.twoFactor.backupCode': 'Backup Code',
'auth.twoFactor.verify': 'Verify',
'auth.twoFactor.verifying': 'Verifying...',
'auth.twoFactor.cancel': 'Cancel',
'auth.layout.pageLabel': 'Authentication page',
'auth.layout.navLabel': 'Authentication navigation',
'common.loading': 'Loading...',
'common.loadingAria': 'Loading in progress',
};
return translations[key] ?? key;
},
language: 'en',
changeLanguage: vi.fn(),
isReady: true,
i18n: { isInitialized: true, changeLanguage: vi.fn() },
}),
}));
// Mock dependencies // Mock dependencies
vi.mock('@/features/auth/store/authStore', () => ({ vi.mock('@/features/auth/store/authStore', () => ({
useAuthStore: vi.fn(), useAuthStore: vi.fn(),
@ -77,14 +126,14 @@ describe('LoginPage', () => {
it('should render login form', () => { it('should render login form', () => {
render(<LoginPage />, { wrapper }); render(<LoginPage />, { wrapper });
expect(screen.getByText('Welcome Back')).toBeInTheDocument(); expect(screen.getByText('Login')).toBeInTheDocument();
expect( expect(
screen.getByText('Sign in to your account'), screen.getByText('Sign in to your Veza account'),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByLabelText('Email')).toBeInTheDocument(); expect(screen.getByLabelText('Email')).toBeInTheDocument();
expect(screen.getByLabelText('Password')).toBeInTheDocument(); expect(screen.getByLabelText('Password')).toBeInTheDocument();
expect( expect(
screen.getByRole('button', { name: 'Sign In' }), screen.getByRole('button', { name: 'Sign in' }),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
@ -125,7 +174,7 @@ describe('LoginPage', () => {
render(<LoginPage />, { wrapper }); render(<LoginPage />, { wrapper });
const form = screen const form = screen
.getByRole('button', { name: 'Sign In' }) .getByRole('button', { name: 'Sign in' })
.closest('form'); .closest('form');
expect(form).toBeInTheDocument(); expect(form).toBeInTheDocument();
@ -154,7 +203,7 @@ describe('LoginPage', () => {
}); });
const form = screen const form = screen
.getByRole('button', { name: 'Sign In' }) .getByRole('button', { name: 'Sign in' })
.closest('form'); .closest('form');
await act(async () => { await act(async () => {
if (form) { if (form) {
@ -175,7 +224,7 @@ describe('LoginPage', () => {
const emailInput = screen.getByLabelText('Email'); const emailInput = screen.getByLabelText('Email');
const passwordInput = screen.getByLabelText('Password'); const passwordInput = screen.getByLabelText('Password');
const submitButton = screen.getByRole('button', { name: 'Sign In' }); const submitButton = screen.getByRole('button', { name: 'Sign in' });
await act(async () => { await act(async () => {
await user.type(emailInput, 'test@example.com'); await user.type(emailInput, 'test@example.com');
@ -224,7 +273,7 @@ describe('LoginPage', () => {
await act(async () => { await act(async () => {
await user.type(screen.getByLabelText('Email'), 'test@example.com'); await user.type(screen.getByLabelText('Email'), 'test@example.com');
await user.type(screen.getByLabelText('Password'), 'password123'); await user.type(screen.getByLabelText('Password'), 'password123');
await user.click(screen.getByRole('button', { name: 'Sign In' })); await user.click(screen.getByRole('button', { name: 'Sign in' }));
}); });
await waitFor(() => { await waitFor(() => {
@ -257,7 +306,7 @@ describe('LoginPage', () => {
render(<LoginPage />, { wrapper }); render(<LoginPage />, { wrapper });
expect( expect(
screen.getByText('Incorrect email or password'), screen.getByText('Invalid email or password'),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
@ -287,9 +336,7 @@ describe('LoginPage', () => {
render(<LoginPage />, { wrapper }); render(<LoginPage />, { wrapper });
expect( expect(
screen.getByText( screen.getByText('Email not verified'),
"Your email is not verified. Check your inbox.",
),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
@ -345,7 +392,7 @@ describe('LoginPage', () => {
render(<LoginPage />, { wrapper }); render(<LoginPage />, { wrapper });
expect( expect(
screen.getByText('Incorrect email or password'), screen.getByText('Invalid email or password'),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
@ -357,12 +404,10 @@ describe('LoginPage', () => {
render(<LoginPage />, { wrapper }); render(<LoginPage />, { wrapper });
// When loading, the button text changes to "Chargement..." // When loading, the button is disabled and has aria-busy
expect(screen.getByText('Chargement...')).toBeInTheDocument(); const loadingButton = screen.getByRole('button', { name: 'Sign in' });
const loadingButton = screen.getByRole('button', {
name: 'Chargement en cours',
});
expect(loadingButton).toBeDisabled(); expect(loadingButton).toBeDisabled();
expect(loadingButton).toHaveAttribute('aria-busy', 'true');
}); });
it('should update form data on input change', async () => { it('should update form data on input change', async () => {
@ -452,34 +497,28 @@ describe('LoginPage', () => {
await waitFor( await waitFor(
() => { () => {
expect(screen.getByText('Format email invalide')).toBeInTheDocument(); expect(screen.getByText('Invalid email format')).toBeInTheDocument();
}, },
{ timeout: 1000 }, { timeout: 1000 },
); );
}); });
it('should validate password length on blur', async () => { it('should not validate password length on login (only required check)', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<LoginPage />, { wrapper }); render(<LoginPage />, { wrapper });
const passwordInput = screen.getByLabelText('Password'); const passwordInput = screen.getByLabelText('Password');
// Type short password // Type short password — should NOT show length error on login
await act(async () => { await act(async () => {
await user.type(passwordInput, '12345'); await user.type(passwordInput, '12345');
await user.tab(); // Trigger blur await user.tab(); // Trigger blur
}); });
await waitFor( // No password length validation error should appear
() => { await new Promise((resolve) => setTimeout(resolve, 200));
expect( expect(screen.queryByText(/at least/i)).not.toBeInTheDocument();
screen.getByText( expect(screen.queryByText(/au moins/i)).not.toBeInTheDocument();
'Le mot de passe doit contenir au moins 6 caractères',
),
).toBeInTheDocument();
},
{ timeout: 1000 },
);
}); });
it('should clear error when user starts typing', async () => { it('should clear error when user starts typing', async () => {
@ -498,7 +537,7 @@ describe('LoginPage', () => {
// Wait for validation error // Wait for validation error
await waitFor( await waitFor(
() => { () => {
const errorText = screen.queryByText('Email requis'); const errorText = screen.queryByText('Email is required');
if (errorText) { if (errorText) {
expect(errorText).toBeInTheDocument(); expect(errorText).toBeInTheDocument();
} }
@ -513,7 +552,7 @@ describe('LoginPage', () => {
// Error should be cleared when typing // Error should be cleared when typing
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const errorAfterTyping = screen.queryByText('Email requis'); const errorAfterTyping = screen.queryByText('Email is required');
// Error may or may not be cleared immediately, but the input should be updated // Error may or may not be cleared immediately, but the input should be updated
expect(emailInput).toHaveValue('t'); expect(emailInput).toHaveValue('t');
}); });
@ -532,7 +571,7 @@ describe('LoginPage', () => {
await waitFor( await waitFor(
() => { () => {
expect(screen.getByText('Format email invalide')).toBeInTheDocument(); expect(screen.getByText('Invalid email format')).toBeInTheDocument();
}, },
{ timeout: 1000 }, { timeout: 1000 },
); );
@ -554,43 +593,34 @@ describe('LoginPage', () => {
); );
}); });
it('should validate password minimum length', async () => { it('should only validate password as required (no length check on login)', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<LoginPage />, { wrapper }); render(<LoginPage />, { wrapper });
const passwordInput = screen.getByLabelText('Password'); const passwordInput = screen.getByLabelText('Password');
// Test short password // Empty password should show required error
await act(async () => { await act(async () => {
await user.type(passwordInput, '12345'); await user.click(passwordInput);
await user.tab(); await user.tab();
}); });
await waitFor( await waitFor(
() => { () => {
expect( expect(screen.getByText('Password is required')).toBeInTheDocument();
screen.getByText(
'Le mot de passe doit contenir au moins 6 caractères',
),
).toBeInTheDocument();
}, },
{ timeout: 1000 }, { timeout: 1000 },
); );
// Test valid password length // Any non-empty password should pass (no length restriction on login)
await act(async () => { await act(async () => {
await user.clear(passwordInput); await user.type(passwordInput, 'a');
await user.type(passwordInput, 'password123');
await user.tab(); await user.tab();
}); });
await waitFor( await waitFor(
() => { () => {
expect( expect(screen.queryByText('Password is required')).not.toBeInTheDocument();
screen.queryByText(
'Le mot de passe doit contenir au moins 6 caractères',
),
).not.toBeInTheDocument();
}, },
{ timeout: 1000 }, { timeout: 1000 },
); );
@ -642,7 +672,7 @@ describe('LoginPage', () => {
const emailInput = screen.getByLabelText('Email'); const emailInput = screen.getByLabelText('Email');
const passwordInput = screen.getByLabelText('Password'); const passwordInput = screen.getByLabelText('Password');
const checkbox = screen.getByLabelText('Remember me'); const checkbox = screen.getByLabelText('Remember me');
const submitButton = screen.getByRole('button', { name: 'Sign In' }); const submitButton = screen.getByRole('button', { name: 'Sign in' });
await act(async () => { await act(async () => {
await user.type(emailInput, 'test@example.com'); await user.type(emailInput, 'test@example.com');
@ -689,7 +719,7 @@ describe('LoginPage', () => {
const checkbox = screen.getByLabelText( const checkbox = screen.getByLabelText(
'Remember me', 'Remember me',
) as HTMLInputElement; ) as HTMLInputElement;
const submitButton = screen.getByRole('button', { name: 'Sign In' }); const submitButton = screen.getByRole('button', { name: 'Sign in' });
// Ensure checkbox is unchecked // Ensure checkbox is unchecked
if (checkbox.checked) { if (checkbox.checked) {

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { Navigate, Link, useNavigate } from 'react-router-dom'; import { Navigate, Link, useNavigate } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
@ -15,18 +15,19 @@ import type { ApiError } from '@/schemas/apiSchemas';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { AlertCircle } from 'lucide-react'; import { AlertCircle } from 'lucide-react';
import { AuthLayout } from '../components/AuthLayout'; import { AuthLayout } from '../components/AuthLayout';
import { useTranslation } from '@/hooks/useTranslation';
function getLoginErrorMessage(error: unknown): string { function getLoginErrorMessage(error: unknown, t: (key: string) => string): string {
if (error == null) return ''; if (error == null) return '';
if (typeof error === 'object' && error !== null && 'message' in error && 'code' in error) { if (typeof error === 'object' && error !== null && 'message' in error && 'code' in error) {
return formatApiErrorMessage(error as ApiError); return formatApiErrorMessage(error as ApiError);
} }
if (error instanceof Error) { if (error instanceof Error) {
const msg = error.message?.toLowerCase() ?? ''; const msg = error.message?.toLowerCase() ?? '';
if (msg.includes('invalid credentials') || msg.includes('401')) return 'Incorrect email or password'; if (msg.includes('invalid credentials') || msg.includes('401')) return t('auth.login.errors.invalidCredentials');
if (msg.includes('email not verified')) return "Your email is not verified. Check your inbox."; if (msg.includes('email not verified')) return t('auth.login.errors.emailNotVerified');
if (msg.includes('network')) return 'Connection error. Check your internet.'; if (msg.includes('network')) return t('auth.login.errors.connectionError');
return error.message || 'An error occurred. Please try again.'; return error.message || t('auth.login.errors.genericError');
} }
return String(error); return String(error);
} }
@ -34,10 +35,17 @@ function getLoginErrorMessage(error: unknown): string {
type Pending2FA = { email: string; password: string; remember_me: boolean }; type Pending2FA = { email: string; password: string; remember_me: boolean };
export function LoginPage() { export function LoginPage() {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { isAuthenticated, isLoading, complete2FALogin, error: authStoreError } = useAuthStore(); const { isAuthenticated, isLoading, complete2FALogin } = useAuthStore();
const { mutate: handleLogin, isPending: loading, error } = useLogin(); const { mutate: handleLogin, isPending: loading, error: mutationError } = useLogin();
// BUG #11 fix: Use a ref to persist the error message across re-renders caused by auth state changes.
// The mutation error gets cleared when the component remounts, so we keep a stable copy.
const [displayError, setDisplayError] = useState<string | null>(null);
const formDataRef = useRef<LoginFormData>({ email: '', password: '' });
const [formData, setFormData] = useState<LoginFormData>({ const [formData, setFormData] = useState<LoginFormData>({
email: '', email: '',
password: '', password: '',
@ -50,14 +58,11 @@ export function LoginPage() {
const [pending2FA, setPending2FA] = useState<Pending2FA | null>(null); const [pending2FA, setPending2FA] = useState<Pending2FA | null>(null);
const [loading2FA, setLoading2FA] = useState(false); const [loading2FA, setLoading2FA] = useState(false);
const [error2FA, setError2FA] = useState<string | null>(null); const [error2FA, setError2FA] = useState<string | null>(null);
const [loginError, setLoginError] = useState<string | null>(() => {
const stored = sessionStorage.getItem('login_error'); // Keep formDataRef in sync
if (stored) { useEffect(() => {
sessionStorage.removeItem('login_error'); formDataRef.current = formData;
return stored; }, [formData]);
}
return null;
});
// Charger l'email sauvegardé au montage // Charger l'email sauvegardé au montage
useEffect(() => { useEffect(() => {
@ -68,7 +73,14 @@ export function LoginPage() {
} }
}, []); }, []);
// Rediriger si déjà connecté (user data géré par React Query, pas dans persist) // Sync mutation error to display error
useEffect(() => {
if (mutationError) {
setDisplayError(getLoginErrorMessage(mutationError, t));
}
}, [mutationError, t]);
// Rediriger si déjà connecté
if (isAuthenticated && !isLoading && !loading) { if (isAuthenticated && !isLoading && !loading) {
return <Navigate to="/dashboard" replace />; return <Navigate to="/dashboard" replace />;
} }
@ -79,13 +91,11 @@ export function LoginPage() {
): string | undefined => { ): string | undefined => {
switch (field) { switch (field) {
case 'email': case 'email':
if (!value) return 'Email requis'; if (!value) return t('auth.login.errors.emailRequired');
if (!/\S+@\S+\.\S+/.test(value)) return 'Format email invalide'; if (!/\S+@\S+\.\S+/.test(value)) return t('auth.login.errors.emailInvalid');
return undefined; return undefined;
case 'password': case 'password':
if (!value) return 'Mot de passe requis'; if (!value) return t('auth.login.errors.passwordRequired');
if (value.length < 6)
return 'Le mot de passe doit contenir au moins 6 caractères';
return undefined; return undefined;
default: default:
return undefined; return undefined;
@ -111,8 +121,7 @@ export function LoginPage() {
const handleChange = (field: keyof LoginFormData, value: string) => { const handleChange = (field: keyof LoginFormData, value: string) => {
setFormData({ ...formData, [field]: value }); setFormData({ ...formData, [field]: value });
if (loginError) setLoginError(null); if (displayError) setDisplayError(null);
if (authStoreError) useAuthStore.getState().clearError?.();
if (errors[field]) { if (errors[field]) {
setErrors({ ...errors, [field]: undefined }); setErrors({ ...errors, [field]: undefined });
} }
@ -126,7 +135,7 @@ export function LoginPage() {
} else { } else {
localStorage.removeItem('rememberedEmail'); localStorage.removeItem('rememberedEmail');
} }
setLoginError(null); setDisplayError(null);
handleLogin( handleLogin(
{ ...formData, remember_me }, { ...formData, remember_me },
{ {
@ -144,9 +153,7 @@ export function LoginPage() {
navigate('/dashboard', { replace: true }); navigate('/dashboard', { replace: true });
}, },
onError: (err) => { onError: (err) => {
const msg = getLoginErrorMessage(err); setDisplayError(getLoginErrorMessage(err, t));
setLoginError(msg);
sessionStorage.setItem('login_error', msg);
logger.error('Login error', { logger.error('Login error', {
error: err instanceof Error ? err.message : String(err), error: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined, stack: err instanceof Error ? err.stack : undefined,
@ -185,7 +192,7 @@ export function LoginPage() {
setPending2FA(null); setPending2FA(null);
navigate('/dashboard', { replace: true }); navigate('/dashboard', { replace: true });
} catch (err) { } catch (err) {
setError2FA(getLoginErrorMessage(err)); setError2FA(getLoginErrorMessage(err, t));
logger.error('2FA login error', { logger.error('2FA login error', {
error: err instanceof Error ? err.message : String(err), error: err instanceof Error ? err.message : String(err),
}); });
@ -197,9 +204,9 @@ export function LoginPage() {
if (show2FA && pending2FA) { if (show2FA && pending2FA) {
return ( return (
<AuthLayout <AuthLayout
title="Two-factor authentication" title={t('auth.twoFactor.title')}
subtitle="Enter the code from your authenticator app" subtitle={t('auth.twoFactor.subtitle')}
footerLinks={[{ label: 'Back to sign in', to: '/login' }]} footerLinks={[{ label: t('auth.twoFactor.backToSignIn'), to: '/login' }]}
> >
<div className="space-y-6"> <div className="space-y-6">
{error2FA && ( {error2FA && (
@ -227,9 +234,9 @@ export function LoginPage() {
return ( return (
<AuthLayout <AuthLayout
title="Welcome Back" title={t('auth.login.title')}
subtitle="Sign in to your account" subtitle={t('auth.login.subtitle')}
footerLinks={[{ label: "Don't have an account? Sign up", to: '/register' }]} footerLinks={[{ label: t('auth.login.footerLink'), to: '/register' }]}
> >
<div className="space-y-6"> <div className="space-y-6">
{/* OAuth providers + divider (hidden when no providers) */} {/* OAuth providers + divider (hidden when no providers) */}
@ -255,27 +262,27 @@ export function LoginPage() {
<div className="w-full section-divider" /> <div className="w-full section-divider" />
</div> </div>
<div className="relative flex justify-center text-xs"> <div className="relative flex justify-center text-xs">
<span className="bg-card/80 backdrop-blur-sm px-4 text-muted-foreground/70">or continue with</span> <span className="bg-card/80 backdrop-blur-sm px-4 text-muted-foreground/70">{t('auth.login.orContinueWith')}</span>
</div> </div>
</div> </div>
</> </>
)} )}
<form onSubmit={onSubmit} className="space-y-4" data-testid="login-form"> <form onSubmit={onSubmit} className="space-y-4" data-testid="login-form">
{(loginError || error) && ( {displayError && (
<div <div
className="bg-destructive/10 border border-destructive/30 text-destructive px-4 py-3 rounded-lg text-sm flex items-center gap-2 animate-in fade-in slide-in-from-top-1" className="bg-destructive/10 border border-destructive/30 text-destructive px-4 py-3 rounded-lg text-sm flex items-center gap-2 animate-in fade-in slide-in-from-top-1"
role="alert" role="alert"
> >
<AlertCircle className="w-4 h-4 flex-shrink-0" /> <AlertCircle className="w-4 h-4 flex-shrink-0" />
<p>{loginError || getLoginErrorMessage(error)}</p> <p>{displayError}</p>
</div> </div>
)} )}
<div className="space-y-4"> <div className="space-y-4">
<AuthInput <AuthInput
type="email" type="email"
label="Email" label={t('auth.login.email')}
value={formData.email} value={formData.email}
autoComplete="email" autoComplete="email"
onChange={(e) => handleChange('email', e.target.value)} onChange={(e) => handleChange('email', e.target.value)}
@ -285,7 +292,7 @@ export function LoginPage() {
/> />
<AuthInput <AuthInput
type="password" type="password"
label="Password" label={t('auth.login.password')}
value={formData.password} value={formData.password}
autoComplete="current-password" autoComplete="current-password"
onChange={(e) => handleChange('password', e.target.value)} onChange={(e) => handleChange('password', e.target.value)}
@ -302,13 +309,13 @@ export function LoginPage() {
id="remember_me" id="remember_me"
checked={remember_me} checked={remember_me}
onCheckedChange={(checked) => setRemember_me(checked)} onCheckedChange={(checked) => setRemember_me(checked)}
label="Remember me" label={t('auth.login.rememberMe')}
/> />
<Link <Link
to="/forgot-password" to="/forgot-password"
className="text-sm text-muted-foreground hover:text-foreground hover:underline underline-offset-4 transition-colors duration-[var(--duration-fast)] flex-shrink-0" className="text-sm text-muted-foreground hover:text-foreground hover:underline underline-offset-4 transition-colors duration-[var(--duration-fast)] flex-shrink-0"
> >
Forgot password? {t('auth.login.forgotPassword')}
</Link> </Link>
</div> </div>
@ -318,7 +325,7 @@ export function LoginPage() {
className="w-full bg-primary text-primary-foreground hover:brightness-110 shadow-md shadow-primary/20 transition-all duration-[var(--sumi-duration-normal)]" className="w-full bg-primary text-primary-foreground hover:brightness-110 shadow-md shadow-primary/20 transition-all duration-[var(--sumi-duration-normal)]"
data-testid="login-submit" data-testid="login-submit"
> >
Sign In {t('auth.login.loginButton')}
</AuthButton> </AuthButton>
</form> </form>
</div> </div>

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { within, userEvent } from '@storybook/test'; import { within, userEvent } from 'storybook/test';
import { ResetPasswordPage } from './ResetPasswordPage'; import { ResetPasswordPage } from './ResetPasswordPage';
/** /**

View file

@ -1,18 +1,22 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { Loader2 } from 'lucide-react';
import { AuthLayout } from '../components/AuthLayout'; import { AuthLayout } from '../components/AuthLayout';
import { AuthInput } from '../components/AuthInput'; import { AuthInput } from '../components/AuthInput';
import { AuthButton } from '../components/AuthButton'; import { AuthButton } from '../components/AuthButton';
import { AuthErrorMessage } from '../components/AuthErrorMessage';
import { PasswordStrengthIndicator } from '../components/PasswordStrengthIndicator'; import { PasswordStrengthIndicator } from '../components/PasswordStrengthIndicator';
import { usePasswordReset } from '../hooks/usePasswordReset'; import { usePasswordReset } from '../hooks/usePasswordReset';
import { useTranslation } from '@/hooks/useTranslation';
import { resetPasswordSchema } from '@/schemas/validation';
import type { ResetPasswordFormData } from '../types'; import type { ResetPasswordFormData } from '../types';
export function ResetPasswordPage() { export function ResetPasswordPage() {
const { t } = useTranslation();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { handleReset, loading, error, success } = usePasswordReset(); const { handleReset, loading, error, success } = usePasswordReset();
const [token, setToken] = useState<string | null>(null); const [token, setToken] = useState<string | null>(null);
const [countdown, setCountdown] = useState(3);
const [formData, setFormData] = useState<ResetPasswordFormData>({ const [formData, setFormData] = useState<ResetPasswordFormData>({
token: '', token: '',
password: '', password: '',
@ -22,7 +26,11 @@ export function ResetPasswordPage() {
Partial<Record<keyof ResetPasswordFormData, string>> Partial<Record<keyof ResetPasswordFormData, string>>
>({}); >({});
// Extraire le token depuis l'URL useEffect(() => {
document.title = t('auth.resetPassword.pageTitle');
}, [t]);
// Extract token from URL
useEffect(() => { useEffect(() => {
const tokenParam = searchParams.get('token'); const tokenParam = searchParams.get('token');
if (tokenParam) { if (tokenParam) {
@ -31,40 +39,52 @@ export function ResetPasswordPage() {
} }
}, [searchParams]); }, [searchParams]);
// Rediriger vers login après succès // Redirect to login after success with countdown
useEffect(() => { useEffect(() => {
if (success) { if (success) {
const timer = setTimeout(() => { setCountdown(3);
navigate('/login', { replace: true }); const interval = setInterval(() => {
}, 3000); setCountdown((prev) => {
return () => clearTimeout(timer); if (prev <= 1) {
clearInterval(interval);
navigate('/login', { replace: true });
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
} }
return undefined; return undefined;
}, [success, navigate]); }, [success, navigate]);
const validate = (): boolean => { const validate = useCallback((): boolean => {
const newErrors: Partial<Record<keyof ResetPasswordFormData, string>> = {}; const result = resetPasswordSchema.safeParse(formData);
if (!result.success) {
if (!formData.password) { const newErrors: Partial<Record<keyof ResetPasswordFormData, string>> = {};
newErrors.password = 'Mot de passe requis'; for (const issue of result.error.issues) {
} else if (formData.password.length < 8) { const field = issue.path[0] as keyof ResetPasswordFormData;
newErrors.password = if (!newErrors[field]) {
'Le mot de passe doit contenir au moins 8 caractères'; if (field === 'password') {
newErrors[field] = !formData.password
? t('auth.resetPassword.errors.passwordRequired')
: t('auth.resetPassword.errors.passwordTooShort');
} else if (field === 'confirmPassword') {
newErrors[field] = !formData.confirmPassword
? t('auth.resetPassword.errors.confirmRequired')
: t('auth.resetPassword.errors.passwordMismatch');
}
}
}
setErrors(newErrors);
return false;
} }
setErrors({});
if (!formData.confirmPassword) { return true;
newErrors.confirmPassword = 'Confirmation du mot de passe requise'; }, [formData, t]);
} else if (formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = 'Les mots de passe ne correspondent pas';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleChange = (field: keyof ResetPasswordFormData, value: string) => { const handleChange = (field: keyof ResetPasswordFormData, value: string) => {
setFormData({ ...formData, [field]: value }); setFormData({ ...formData, [field]: value });
// Clear error for this field when user starts typing
if (errors[field]) { if (errors[field]) {
setErrors({ ...errors, [field]: undefined }); setErrors({ ...errors, [field]: undefined });
} }
@ -72,48 +92,48 @@ export function ResetPasswordPage() {
const handleBlur = (field: keyof ResetPasswordFormData) => { const handleBlur = (field: keyof ResetPasswordFormData) => {
const value = formData[field]; const value = formData[field];
let error: string | undefined; let fieldError: string | undefined;
if (field === 'password') { if (field === 'password') {
if (!value) { if (!value) {
error = 'Mot de passe requis'; fieldError = t('auth.resetPassword.errors.passwordRequired');
} else if (value.length < 8) { } else {
error = 'Le mot de passe doit contenir au moins 8 caractères'; const result = resetPasswordSchema.safeParse({ ...formData, [field]: value });
if (!result.success) {
const pwIssue = result.error.issues.find((i) => i.path[0] === 'password');
if (pwIssue) {
fieldError = t('auth.resetPassword.errors.passwordTooShort');
}
}
} }
} else if (field === 'confirmPassword') { } else if (field === 'confirmPassword') {
if (!value) { if (!value) {
error = 'Confirmation du mot de passe requise'; fieldError = t('auth.resetPassword.errors.confirmRequired');
} else if (formData.password !== value) { } else if (formData.password !== value) {
error = 'Les mots de passe ne correspondent pas'; fieldError = t('auth.resetPassword.errors.passwordMismatch');
} }
} }
if (error) { setErrors((prev) => ({ ...prev, [field]: fieldError }));
setErrors({ ...errors, [field]: error });
} else {
setErrors({ ...errors, [field]: undefined });
}
}; };
const onSubmit = async (e: React.FormEvent) => { const onSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!token) { if (!token) return;
return;
}
if (validate()) { if (validate()) {
await handleReset(formData); await handleReset(formData);
} }
}; };
// Afficher un message si le token est invalide ou manquant // State: invalid/missing token
if (!token) { if (!token) {
return ( return (
<AuthLayout <AuthLayout
title="Lien de réinitialisation invalide" title={t('auth.resetPassword.invalidToken.title')}
subtitle="Le lien de réinitialisation est invalide ou a expiré" subtitle={t('auth.resetPassword.invalidToken.subtitle')}
footerLinks={[ footerLinks={[
{ label: 'Demander un nouveau lien', to: '/forgot-password' }, { label: t('auth.resetPassword.requestNewLink'), to: '/forgot-password' },
{ label: 'Retour à la connexion', to: '/login' }, { label: t('auth.resetPassword.backToLogin'), to: '/login' },
]} ]}
> >
<div <div
@ -122,10 +142,9 @@ export function ResetPasswordPage() {
aria-live="assertive" aria-live="assertive"
> >
<div className="bg-destructive/10 border border-destructive text-destructive px-4 py-4 rounded"> <div className="bg-destructive/10 border border-destructive text-destructive px-4 py-4 rounded">
<p className="font-medium">Lien invalide</p> <p className="font-medium">{t('auth.resetPassword.invalidToken.heading')}</p>
<p className="text-sm mt-1"> <p className="text-sm mt-1">
Le lien de réinitialisation est invalide ou a expiré. Veuillez {t('auth.resetPassword.invalidToken.body')}
demander un nouveau lien.
</p> </p>
</div> </div>
</div> </div>
@ -133,52 +152,74 @@ export function ResetPasswordPage() {
); );
} }
// Afficher le message de succès // State: success
if (success) { if (success) {
return ( return (
<AuthLayout <AuthLayout
title="Mot de passe réinitialisé" title={t('auth.resetPassword.success.title')}
subtitle="Votre mot de passe a été modifié avec succès" subtitle={t('auth.resetPassword.success.subtitle')}
footerLinks={[{ label: 'Retour à la connexion', to: '/login' }]} footerLinks={[{ label: t('auth.resetPassword.backToLogin'), to: '/login' }]}
> >
<div className="text-center space-y-4" role="status" aria-live="polite"> <div className="text-center space-y-4" role="status" aria-live="polite">
<div <div
className="bg-success/10 border border-success text-success px-4 py-4 rounded" className="bg-success/10 border border-success text-success px-4 py-4 rounded"
role="alert" role="alert"
> >
<p className="font-medium">Succès !</p> <p className="font-medium">{t('auth.resetPassword.success.heading')}</p>
<p className="text-sm mt-1"> <p className="text-sm mt-1">
Votre mot de passe a é réinitialisé avec succès. Vous allez être {t('auth.resetPassword.success.body')}
redirigé vers la page de connexion...
</p> </p>
</div> </div>
<p className="text-sm text-muted-foreground">
{t('auth.resetPassword.success.redirecting', { seconds: countdown })}
</p>
</div> </div>
</AuthLayout> </AuthLayout>
); );
} }
// State: form
return ( return (
<AuthLayout <AuthLayout
title="Réinitialiser le mot de passe" title={t('auth.resetPassword.title')}
subtitle="Entrez votre nouveau mot de passe" subtitle={t('auth.resetPassword.subtitle')}
footerLinks={[{ label: 'Retour à la connexion', to: '/login' }]} footerLinks={[{ label: t('auth.resetPassword.backToLogin'), to: '/login' }]}
> >
<form <form
onSubmit={onSubmit} onSubmit={onSubmit}
className="space-y-4" className="space-y-4"
aria-label="Formulaire de réinitialisation de mot de passe" aria-label={t('auth.resetPassword.formAriaLabel')}
data-testid="reset-password-form"
> >
{error && <AuthErrorMessage message={error.message} />} {/* Hidden username field for password managers */}
<input
type="hidden"
name="username"
autoComplete="username"
value=""
aria-hidden="true"
/>
{error && (
<div
className="bg-destructive/10 border border-destructive text-destructive px-4 py-4 rounded"
role="alert"
aria-live="assertive"
>
{error.message}
</div>
)}
<AuthInput <AuthInput
type="password" type="password"
label="Nouveau mot de passe" label={t('auth.resetPassword.password')}
value={formData.password} value={formData.password}
onChange={(e) => handleChange('password', e.target.value)} onChange={(e) => handleChange('password', e.target.value)}
onBlur={() => handleBlur('password')} onBlur={() => handleBlur('password')}
error={errors.password} error={errors.password}
required required
autoComplete="new-password" autoComplete="new-password"
showPasswordToggle
/> />
<div aria-live="polite" aria-atomic="true"> <div aria-live="polite" aria-atomic="true">
<PasswordStrengthIndicator password={formData.password} /> <PasswordStrengthIndicator password={formData.password} />
@ -186,17 +227,25 @@ export function ResetPasswordPage() {
<AuthInput <AuthInput
type="password" type="password"
label="Confirmer le mot de passe" label={t('auth.resetPassword.confirmPassword')}
value={formData.confirmPassword} value={formData.confirmPassword}
onChange={(e) => handleChange('confirmPassword', e.target.value)} onChange={(e) => handleChange('confirmPassword', e.target.value)}
onBlur={() => handleBlur('confirmPassword')} onBlur={() => handleBlur('confirmPassword')}
error={errors.confirmPassword} error={errors.confirmPassword}
required required
autoComplete="new-password" autoComplete="new-password"
showPasswordToggle
/> />
<AuthButton type="submit" loading={loading}> <AuthButton type="submit" loading={loading} data-testid="reset-password-submit">
Réinitialiser le mot de passe {loading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin inline" />
{t('auth.resetPassword.submitButton')}
</>
) : (
t('auth.resetPassword.submitButton')
)}
</AuthButton> </AuthButton>
</form> </form>
</AuthLayout> </AuthLayout>

View file

@ -13,6 +13,54 @@ vi.mock('@/services/api/auth', () => ({
}, },
})); }));
// Mock i18n
vi.mock('@/hooks/useTranslation', () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, string>) => {
const translations: Record<string, string> = {
'auth.verifyEmail.title.verifying': 'Email Verification',
'auth.verifyEmail.title.success': 'Email Verified',
'auth.verifyEmail.title.error': 'Email Verification',
'auth.verifyEmail.subtitle.verifying': 'Verification in progress...',
'auth.verifyEmail.subtitle.success':
'Your email has been successfully verified',
'auth.verifyEmail.subtitle.error': 'An error occurred',
'auth.verifyEmail.message.verifying': 'Verifying your email...',
'auth.verifyEmail.message.success':
'Your email has been successfully verified!',
'auth.verifyEmail.message.invalidLink':
'Invalid or missing verification link',
'auth.verifyEmail.message.defaultError': 'Verification failed',
'auth.verifyEmail.message.resendSuccess':
'Verification email sent! Please check your inbox.',
'auth.verifyEmail.message.emailNotFound':
'Email not found. Please register again or contact support.',
'auth.verifyEmail.message.resendError': 'Failed to send the email',
'auth.verifyEmail.button.retry': 'Retry',
'auth.verifyEmail.button.resend': 'Resend verification email',
'auth.verifyEmail.button.resendCooldown': `Resend in ${opts?.seconds ?? ''}s`,
'auth.verifyEmail.button.resendCooldownAriaLabel': `Resend verification email in ${opts?.seconds ?? ''} seconds`,
'auth.verifyEmail.button.resendAriaLabel':
'Resend verification email',
'auth.verifyEmail.success.title': 'Success!',
'auth.verifyEmail.success.redirecting':
'You will be redirected to the login page...',
'auth.verifyEmail.error.title': 'Error',
'auth.verifyEmail.srOnly.verifying':
'Verifying your email, please wait',
'auth.verifyEmail.footer.backToLogin': 'Back to login',
'auth.layout.pageLabel': 'Authentication page',
'auth.layout.navLabel': 'Authentication navigation',
};
return translations[key] ?? key;
},
language: 'en',
changeLanguage: vi.fn(),
isReady: true,
i18n: { isInitialized: true, changeLanguage: vi.fn() },
}),
}));
const mockNavigate = vi.fn(); const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => { vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom'); const actual = await vi.importActual('react-router-dom');
@ -37,6 +85,15 @@ describe('VerifyEmailPage', () => {
vi.clearAllTimers(); vi.clearAllTimers();
}); });
it('should display error message when token is missing', () => {
render(<VerifyEmailPage />, { wrapper });
expect(screen.getByTestId('verify-email-error')).toBeInTheDocument();
expect(screen.getByTestId('verify-email-message')).toHaveTextContent(
'Invalid or missing verification link',
);
});
it('should render verifying state when token is present', async () => { it('should render verifying state when token is present', async () => {
vi.mocked(authApi.verifyEmail).mockResolvedValue({ vi.mocked(authApi.verifyEmail).mockResolvedValue({
message: 'Email verified', message: 'Email verified',
@ -50,20 +107,8 @@ describe('VerifyEmailPage', () => {
render(<VerifyEmailPage />, { wrapper: wrapperWithToken }); render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
// Should show verifying state initially const heading = document.querySelector('h1');
await waitFor(() => { expect(heading).toBeInTheDocument();
const heading = document.querySelector('h1');
expect(heading).toBeInTheDocument();
});
});
it('should display error message when token is missing', () => {
render(<VerifyEmailPage />, { wrapper });
expect(screen.getByText("Vérification de l'email")).toBeInTheDocument();
expect(
screen.getByText('Lien de vérification invalide ou manquant'),
).toBeInTheDocument();
}); });
it('should extract token from URL and verify email', async () => { it('should extract token from URL and verify email', async () => {
@ -79,7 +124,6 @@ describe('VerifyEmailPage', () => {
render(<VerifyEmailPage />, { wrapper: wrapperWithToken }); render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
// Wait for verification to complete
await waitFor( await waitFor(
() => { () => {
expect(authApi.verifyEmail).toHaveBeenCalledWith({ token: 'abc123' }); expect(authApi.verifyEmail).toHaveBeenCalledWith({ token: 'abc123' });
@ -101,17 +145,16 @@ describe('VerifyEmailPage', () => {
render(<VerifyEmailPage />, { wrapper: wrapperWithToken }); render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
// Wait for success message
await waitFor( await waitFor(
() => { () => {
expect(screen.getByText('Email vérifié')).toBeInTheDocument(); expect(screen.getByTestId('verify-email-success')).toBeInTheDocument();
}, },
{ timeout: 2000 }, { timeout: 2000 },
); );
}); });
it('should display error message when verification fails', async () => { it('should display error message when verification fails', async () => {
const error = { message: 'Token invalide ou expiré' }; const error = { message: 'Token invalid or expired' };
vi.mocked(authApi.verifyEmail).mockRejectedValue(error); vi.mocked(authApi.verifyEmail).mockRejectedValue(error);
const wrapperWithToken = ({ children }: { children: React.ReactNode }) => ( const wrapperWithToken = ({ children }: { children: React.ReactNode }) => (
@ -122,12 +165,12 @@ describe('VerifyEmailPage', () => {
render(<VerifyEmailPage />, { wrapper: wrapperWithToken }); render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
// Wait for error message
await waitFor( await waitFor(
() => { () => {
expect( expect(screen.getByTestId('verify-email-error')).toBeInTheDocument();
screen.getByText('Token invalide ou expiré'), expect(screen.getByTestId('verify-email-message')).toHaveTextContent(
).toBeInTheDocument(); 'Token invalid or expired',
);
}, },
{ timeout: 3000 }, { timeout: 3000 },
); );
@ -146,12 +189,10 @@ describe('VerifyEmailPage', () => {
render(<VerifyEmailPage />, { wrapper: wrapperWithToken }); render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
// Wait for success
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Email vérifié')).toBeInTheDocument(); expect(screen.getByTestId('verify-email-success')).toBeInTheDocument();
}); });
// Wait for the redirect timer (3 seconds)
await waitFor( await waitFor(
() => { () => {
expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true }); expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true });
@ -160,7 +201,7 @@ describe('VerifyEmailPage', () => {
); );
}); });
it('should display retry button when verification fails', async () => { it('should display retry button when verification fails with token', async () => {
const error = { message: 'Verification failed' }; const error = { message: 'Verification failed' };
vi.mocked(authApi.verifyEmail).mockRejectedValue(error); vi.mocked(authApi.verifyEmail).mockRejectedValue(error);
@ -172,10 +213,9 @@ describe('VerifyEmailPage', () => {
render(<VerifyEmailPage />, { wrapper: wrapperWithToken }); render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
// Wait for error state
await waitFor( await waitFor(
() => { () => {
expect(screen.getByText('Réessayer')).toBeInTheDocument(); expect(screen.getByTestId('verify-email-retry')).toBeInTheDocument();
}, },
{ timeout: 3000 }, { timeout: 3000 },
); );
@ -196,24 +236,21 @@ describe('VerifyEmailPage', () => {
render(<VerifyEmailPage />, { wrapper: wrapperWithToken }); render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
// Wait for error state
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Réessayer')).toBeInTheDocument(); expect(screen.getByTestId('verify-email-retry')).toBeInTheDocument();
}); });
// Click retry button const retryButton = screen.getByTestId('verify-email-retry');
const retryButton = screen.getByText('Réessayer');
await act(async () => { await act(async () => {
await user.click(retryButton); await user.click(retryButton);
}); });
// Should call verifyEmail again
await waitFor(() => { await waitFor(() => {
expect(authApi.verifyEmail).toHaveBeenCalledTimes(2); expect(authApi.verifyEmail).toHaveBeenCalledTimes(2);
}); });
}); });
it('should display resend email button', async () => { it('should display resend email button in error state', async () => {
const error = { message: 'Verification failed' }; const error = { message: 'Verification failed' };
vi.mocked(authApi.verifyEmail).mockRejectedValue(error); vi.mocked(authApi.verifyEmail).mockRejectedValue(error);
@ -225,12 +262,9 @@ describe('VerifyEmailPage', () => {
render(<VerifyEmailPage />, { wrapper: wrapperWithToken }); render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
// Wait for error state
await waitFor( await waitFor(
() => { () => {
expect( expect(screen.getByTestId('verify-email-resend')).toBeInTheDocument();
screen.getByText(/Renvoyer l'email de vérification/),
).toBeInTheDocument();
}, },
{ timeout: 3000 }, { timeout: 3000 },
); );
@ -254,23 +288,18 @@ describe('VerifyEmailPage', () => {
render(<VerifyEmailPage />, { wrapper: wrapperWithToken }); render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
// Wait for error state
await waitFor( await waitFor(
() => { () => {
expect( expect(screen.getByTestId('verify-email-resend')).toBeInTheDocument();
screen.getByText(/Renvoyer l'email de vérification/),
).toBeInTheDocument();
}, },
{ timeout: 3000 }, { timeout: 3000 },
); );
// Click resend button const resendButton = screen.getByTestId('verify-email-resend');
const resendButton = screen.getByText(/Renvoyer l'email de vérification/);
await act(async () => { await act(async () => {
await user.click(resendButton); await user.click(resendButton);
}); });
// Should call resendVerification
await waitFor( await waitFor(
() => { () => {
expect(authApi.resendVerification).toHaveBeenCalledWith({ expect(authApi.resendVerification).toHaveBeenCalledWith({
@ -281,7 +310,7 @@ describe('VerifyEmailPage', () => {
); );
}); });
it('should set cooldown after resending email', async () => { it('should show success styled container after successful resend', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const error = { message: 'Verification failed' }; const error = { message: 'Verification failed' };
vi.mocked(authApi.verifyEmail).mockRejectedValue(error); vi.mocked(authApi.verifyEmail).mockRejectedValue(error);
@ -299,39 +328,27 @@ describe('VerifyEmailPage', () => {
render(<VerifyEmailPage />, { wrapper: wrapperWithToken }); render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
// Wait for error state await waitFor(() => {
await waitFor( expect(screen.getByTestId('verify-email-resend')).toBeInTheDocument();
() => { });
expect(
screen.getByText(/Renvoyer l'email de vérification/),
).toBeInTheDocument();
},
{ timeout: 2000 },
);
// Click resend button const resendButton = screen.getByTestId('verify-email-resend');
const resendButton = screen.getByText(/Renvoyer l'email de vérification/);
await act(async () => { await act(async () => {
await user.click(resendButton); await user.click(resendButton);
}); });
// Verify that resendVerification was called await waitFor(() => {
await waitFor( expect(
() => { screen.getByTestId('verify-email-resend-success'),
expect(authApi.resendVerification).toHaveBeenCalledWith({ ).toBeInTheDocument();
email: 'test@example.com', });
});
},
{ timeout: 2000 },
);
}); });
it('should handle resend when email is not found', async () => { it('should handle resend when email is not found in localStorage', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const error = { message: 'Verification failed' }; const error = { message: 'Verification failed' };
vi.mocked(authApi.verifyEmail).mockRejectedValue(error); vi.mocked(authApi.verifyEmail).mockRejectedValue(error);
// No email in localStorage
localStorage.clear(); localStorage.clear();
const wrapperWithToken = ({ children }: { children: React.ReactNode }) => ( const wrapperWithToken = ({ children }: { children: React.ReactNode }) => (
@ -342,32 +359,57 @@ describe('VerifyEmailPage', () => {
render(<VerifyEmailPage />, { wrapper: wrapperWithToken }); render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
// Wait for error state await waitFor(() => {
await waitFor( expect(screen.getByTestId('verify-email-resend')).toBeInTheDocument();
() => { });
expect(
screen.getByText(/Renvoyer l'email de vérification/),
).toBeInTheDocument();
},
{ timeout: 2000 },
);
// Click resend button const resendButton = screen.getByTestId('verify-email-resend');
const resendButton = screen.getByText(/Renvoyer l'email de vérification/);
await act(async () => { await act(async () => {
await user.click(resendButton); await user.click(resendButton);
}); });
// resendVerification should NOT be called since email is missing
await new Promise((resolve) => setTimeout(resolve, 200)); await new Promise((resolve) => setTimeout(resolve, 200));
expect(authApi.resendVerification).not.toHaveBeenCalled(); expect(authApi.resendVerification).not.toHaveBeenCalled();
expect(screen.getByTestId('verify-email-message')).toHaveTextContent(
'Email not found',
);
}); });
it('should display footer links', () => { it('should display footer link to login page', () => {
render(<VerifyEmailPage />, { wrapper }); render(<VerifyEmailPage />, { wrapper });
const loginLink = screen.getByText('Retour à la connexion'); const loginLink = screen.getByText('Back to login');
expect(loginLink).toBeInTheDocument(); expect(loginLink).toBeInTheDocument();
expect(loginLink.closest('a')).toHaveAttribute('href', '/login'); expect(loginLink.closest('a')).toHaveAttribute('href', '/login');
}); });
it('should not show retry button when no token is present', () => {
render(<VerifyEmailPage />, { wrapper });
expect(
screen.queryByTestId('verify-email-retry'),
).not.toBeInTheDocument();
expect(screen.getByTestId('verify-email-resend')).toBeInTheDocument();
});
it('should clean pendingVerificationEmail from localStorage after success', async () => {
vi.mocked(authApi.verifyEmail).mockResolvedValue({
message: 'Email verified',
});
localStorage.setItem('pendingVerificationEmail', 'test@example.com');
const wrapperWithToken = ({ children }: { children: React.ReactNode }) => (
<MemoryRouter initialEntries={['/verify-email?token=test-token']}>
{children}
</MemoryRouter>
);
render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
await waitFor(() => {
expect(screen.getByTestId('verify-email-success')).toBeInTheDocument();
});
expect(localStorage.getItem('pendingVerificationEmail')).toBeNull();
});
}); });

View file

@ -3,74 +3,72 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import { AuthLayout } from '../components/AuthLayout'; import { AuthLayout } from '../components/AuthLayout';
import { AuthButton } from '../components/AuthButton'; import { AuthButton } from '../components/AuthButton';
import { authApi } from '@/services/api/auth'; import { authApi } from '@/services/api/auth';
import { useTranslation } from '@/hooks/useTranslation';
import type { ApiError } from '@/schemas/apiSchemas'; import type { ApiError } from '@/schemas/apiSchemas';
export function VerifyEmailPage() { export function VerifyEmailPage() {
const { t } = useTranslation();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
const [status, setStatus] = useState<'verifying' | 'success' | 'error'>( const [status, setStatus] = useState<'verifying' | 'success' | 'error'>(
'verifying', 'verifying',
); );
const [message, setMessage] = useState( const [message, setMessage] = useState('');
'Vérification de votre email en cours...',
);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [resendLoading, setResendLoading] = useState(false); const [resendLoading, setResendLoading] = useState(false);
const [resendCooldown, setResendCooldown] = useState(0); const [resendCooldown, setResendCooldown] = useState(0);
const cooldownIntervalRef = useRef<NodeJS.Timeout | null>(null); const [resendSuccess, setResendSuccess] = useState(false);
const [token, setToken] = useState<string | null>(null); const [token, setToken] = useState<string | null>(null);
const hasVerifiedRef = useRef(false);
// Extraire le token depuis l'URL // Extract token from URL and verify automatically (guarded against Strict Mode double-invocation)
useEffect(() => { useEffect(() => {
const tokenParam = searchParams.get('token'); const tokenParam = searchParams.get('token');
if (tokenParam) { if (tokenParam && !hasVerifiedRef.current) {
hasVerifiedRef.current = true;
setToken(tokenParam); setToken(tokenParam);
// Vérifier l'email automatiquement si le token est présent
handleVerifyEmail(tokenParam); // Clean token from URL to prevent leakage via Referer header / browser history
} else { window.history.replaceState({}, '', '/verify-email');
(async () => {
try {
setLoading(true);
setStatus('verifying');
setMessage(t('auth.verifyEmail.message.verifying'));
await authApi.verifyEmail({ token: tokenParam });
setStatus('success');
setMessage(t('auth.verifyEmail.message.success'));
localStorage.removeItem('pendingVerificationEmail');
} catch (error) {
setStatus('error');
const apiError = error as ApiError;
setMessage(
apiError.message || t('auth.verifyEmail.message.defaultError'),
);
} finally {
setLoading(false);
}
})();
} else if (!tokenParam && !hasVerifiedRef.current) {
setStatus('error'); setStatus('error');
setMessage('Lien de vérification invalide ou manquant'); setMessage(t('auth.verifyEmail.message.invalidLink'));
} }
}, [searchParams]); }, [searchParams, t]);
// Cleanup cooldown interval on unmount // Cooldown timer (simplified: one setTimeout per tick instead of setInterval churn)
useEffect(() => { useEffect(() => {
return () => { if (resendCooldown <= 0) return;
if (cooldownIntervalRef.current) { const timer = setTimeout(
clearInterval(cooldownIntervalRef.current); () => setResendCooldown((c) => c - 1),
} 1000,
}; );
}, []); return () => clearTimeout(timer);
// Gérer le cooldown pour le renvoi d'email
useEffect(() => {
if (resendCooldown > 0) {
cooldownIntervalRef.current = setInterval(() => {
setResendCooldown((prev) => {
if (prev <= 1) {
if (cooldownIntervalRef.current) {
clearInterval(cooldownIntervalRef.current);
}
return 0;
}
return prev - 1;
});
}, 1000);
} else {
if (cooldownIntervalRef.current) {
clearInterval(cooldownIntervalRef.current);
cooldownIntervalRef.current = null;
}
}
return () => {
if (cooldownIntervalRef.current) {
clearInterval(cooldownIntervalRef.current);
}
};
}, [resendCooldown]); }, [resendCooldown]);
// Rediriger vers login après succès // Redirect to login after successful verification
useEffect(() => { useEffect(() => {
if (status === 'success') { if (status === 'success') {
const timer = setTimeout(() => { const timer = setTimeout(() => {
@ -81,102 +79,126 @@ export function VerifyEmailPage() {
return undefined; return undefined;
}, [status, navigate]); }, [status, navigate]);
const handleVerifyEmail = async (emailToken: string) => { // Set document.title based on current status
useEffect(() => {
const titles: Record<typeof status, string> = {
verifying: t('auth.verifyEmail.title.verifying'),
success: t('auth.verifyEmail.title.success'),
error: t('auth.verifyEmail.title.error'),
};
document.title = `${titles[status]} - Veza`;
}, [status, t]);
const handleRetryVerification = async () => {
if (!token || loading) return;
try { try {
setLoading(true); setLoading(true);
setStatus('verifying'); setStatus('verifying');
setMessage('Vérification de votre email en cours...'); setMessage(t('auth.verifyEmail.message.verifying'));
setResendSuccess(false);
await authApi.verifyEmail({ token: emailToken }); await authApi.verifyEmail({ token });
setStatus('success'); setStatus('success');
setMessage('Votre email a été vérifié avec succès !'); setMessage(t('auth.verifyEmail.message.success'));
localStorage.removeItem('pendingVerificationEmail');
} catch (error) { } catch (error) {
setStatus('error'); setStatus('error');
const apiError = error as ApiError; const apiError = error as ApiError;
setMessage(apiError.message || 'La vérification a échoué'); setMessage(
apiError.message || t('auth.verifyEmail.message.defaultError'),
);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const handleResendVerificationEmail = async () => { const handleResendVerificationEmail = async () => {
if (resendCooldown > 0 || resendLoading) { if (resendCooldown > 0 || resendLoading) return;
return;
}
try { try {
setResendLoading(true); setResendLoading(true);
setResendSuccess(false);
// Récupérer l'email depuis localStorage (stocké lors de l'inscription)
const email = localStorage.getItem('pendingVerificationEmail'); const email = localStorage.getItem('pendingVerificationEmail');
if (!email) { if (!email) {
setMessage( setMessage(t('auth.verifyEmail.message.emailNotFound'));
'Email non trouvé. Veuillez vous réinscrire ou contacter le support.',
);
return; return;
} }
await authApi.resendVerification({ email }); await authApi.resendVerification({ email });
// Définir cooldown de 60 secondes
setResendCooldown(60); setResendCooldown(60);
setResendSuccess(true);
// Afficher message de confirmation setMessage(t('auth.verifyEmail.message.resendSuccess'));
setMessage(
'Email de vérification envoyé ! Veuillez vérifier votre boîte mail.',
);
} catch (error) { } catch (error) {
const apiError = error as ApiError; const apiError = error as ApiError;
setMessage(apiError.message || "Échec de l'envoi de l'email"); setResendSuccess(false);
setMessage(
apiError.message || t('auth.verifyEmail.message.resendError'),
);
} finally { } finally {
setResendLoading(false); setResendLoading(false);
} }
}; };
// Afficher le statut de vérification const footerLinks = [
{ label: t('auth.verifyEmail.footer.backToLogin'), to: '/login' },
];
// Verifying state
if (status === 'verifying') { if (status === 'verifying') {
return ( return (
<AuthLayout <AuthLayout
title="Vérification de l'email" title={t('auth.verifyEmail.title.verifying')}
subtitle="Vérification en cours..." subtitle={t('auth.verifyEmail.subtitle.verifying')}
footerLinks={[{ label: 'Retour à la connexion', to: '/login' }]} footerLinks={footerLinks}
> >
<div <div
className="text-center space-y-4" className="text-center space-y-4"
role="status" role="status"
aria-live="polite" aria-live="polite"
aria-busy="true" aria-busy="true"
data-testid="verify-email-spinner"
> >
<div className="flex justify-center" aria-hidden="true"> <div className="flex justify-center" aria-hidden="true">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div> </div>
<p className="text-muted-foreground">{message}</p> <p className="text-muted-foreground" data-testid="verify-email-message">
{message || t('auth.verifyEmail.message.verifying')}
</p>
<span className="sr-only"> <span className="sr-only">
Vérification de votre email en cours, veuillez patienter {t('auth.verifyEmail.srOnly.verifying')}
</span> </span>
</div> </div>
</AuthLayout> </AuthLayout>
); );
} }
// Afficher le message de succès // Success state
if (status === 'success') { if (status === 'success') {
return ( return (
<AuthLayout <AuthLayout
title="Email vérifié" title={t('auth.verifyEmail.title.success')}
subtitle="Votre email a été vérifié avec succès" subtitle={t('auth.verifyEmail.subtitle.success')}
footerLinks={[{ label: 'Retour à la connexion', to: '/login' }]} footerLinks={footerLinks}
> >
<div className="text-center space-y-4" role="status" aria-live="polite"> <div
className="text-center space-y-4"
role="status"
aria-live="polite"
data-testid="verify-email-success"
>
<div <div
className="bg-success/10 border border-success text-success px-4 py-4 rounded" className="bg-success/10 border border-success text-success px-4 py-4 rounded"
role="alert" role="alert"
> >
<p className="font-medium">Succès !</p> <p className="font-medium">{t('auth.verifyEmail.success.title')}</p>
<p className="text-sm mt-1">{message}</p> <p className="text-sm mt-1" data-testid="verify-email-message">
{message}
</p>
<p className="text-xs mt-2 text-muted-foreground"> <p className="text-xs mt-2 text-muted-foreground">
Vous allez être redirigé vers la page de connexion... {t('auth.verifyEmail.success.redirecting')}
</p> </p>
</div> </div>
</div> </div>
@ -184,31 +206,48 @@ export function VerifyEmailPage() {
); );
} }
// Afficher le message d'erreur avec options de retry/resend // Error state with retry/resend options
return ( return (
<AuthLayout <AuthLayout
title="Vérification de l'email" title={t('auth.verifyEmail.title.error')}
subtitle="Une erreur s'est produite" subtitle={t('auth.verifyEmail.subtitle.error')}
footerLinks={[{ label: 'Retour à la connexion', to: '/login' }]} footerLinks={footerLinks}
> >
<div className="text-center space-y-4"> <div className="text-center space-y-4" data-testid="verify-email-error">
<div {resendSuccess ? (
className="bg-destructive/10 border border-destructive text-destructive px-4 py-4 rounded" <div
role="alert" className="bg-success/10 border border-success text-success px-4 py-4 rounded"
aria-live="assertive" role="status"
> aria-live="polite"
<p className="font-medium">Erreur</p> data-testid="verify-email-resend-success"
<p className="text-sm mt-1">{message}</p> >
</div> <p className="font-medium">{t('auth.verifyEmail.success.title')}</p>
<p className="text-sm mt-1" data-testid="verify-email-message">
{message}
</p>
</div>
) : (
<div
className="bg-destructive/10 border border-destructive text-destructive px-4 py-4 rounded"
role="alert"
aria-live="assertive"
>
<p className="font-medium">{t('auth.verifyEmail.error.title')}</p>
<p className="text-sm mt-1" data-testid="verify-email-message">
{message}
</p>
</div>
)}
<div className="space-y-2"> <div className="space-y-2">
{token && ( {token && (
<AuthButton <AuthButton
onClick={() => handleVerifyEmail(token)} onClick={handleRetryVerification}
loading={loading} loading={loading}
type="button" type="button"
data-testid="verify-email-retry"
> >
Réessayer {t('auth.verifyEmail.button.retry')}
</AuthButton> </AuthButton>
)} )}
@ -218,21 +257,30 @@ export function VerifyEmailPage() {
disabled={resendCooldown > 0} disabled={resendCooldown > 0}
type="button" type="button"
variant="secondary" variant="secondary"
data-testid="verify-email-resend"
aria-label={ aria-label={
resendCooldown > 0 resendCooldown > 0
? `Renvoyer l'email de vérification dans ${resendCooldown} secondes` ? t('auth.verifyEmail.button.resendCooldownAriaLabel', {
: "Renvoyer l'email de vérification" seconds: String(resendCooldown),
})
: t('auth.verifyEmail.button.resendAriaLabel')
} }
> >
{resendCooldown > 0 ? ( {resendCooldown > 0 ? (
<> <>
<span className="sr-only"> <span className="sr-only">
Renvoyer dans {resendCooldown} secondes {t('auth.verifyEmail.button.resendCooldownAriaLabel', {
seconds: String(resendCooldown),
})}
</span>
<span aria-hidden="true">
{t('auth.verifyEmail.button.resendCooldown', {
seconds: String(resendCooldown),
})}
</span> </span>
<span aria-hidden="true">Renvoyer dans {resendCooldown}s</span>
</> </>
) : ( ) : (
"Renvoyer l'email de vérification" t('auth.verifyEmail.button.resend')
)} )}
</AuthButton> </AuthButton>
</div> </div>

View file

@ -97,10 +97,7 @@ export const useAuthStore = create<AuthStore>()(
isLoading: false, isLoading: false,
isAuthenticated: false, isAuthenticated: false,
}); });
// Persist login error for display after component remount // Error is available via the mutation's error state — no need for sessionStorage
try {
sessionStorage.setItem('login_error', apiError.message || 'Identifiants incorrects');
} catch { /* ignore */ }
throw error; throw error;
} }
}, },

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test'; import { fn } from 'storybook/test';
import { CreateRoomDialog } from './CreateRoomDialog'; import { CreateRoomDialog } from './CreateRoomDialog';
const meta: Meta<typeof CreateRoomDialog> = { const meta: Meta<typeof CreateRoomDialog> = {

View file

@ -1,14 +1,38 @@
import { useTranslation } from '@/hooks/useTranslation'; import { useTranslation } from '@/hooks/useTranslation';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useDashboard } from '../hooks/useDashboard';
import type { RecentActivity } from '../services/dashboardService';
const TYPE_DOT_COLORS: Record<string, string> = {
track_upload: 'bg-[var(--sumi-accent)]',
message_received: 'bg-[var(--sumi-sage)]',
favorite_added: 'bg-[var(--sumi-vermillion)]',
playlist_created: 'bg-primary',
comment_added: 'bg-warning',
};
function formatRelativeTime(timestamp: string): string {
const now = Date.now();
const then = new Date(timestamp).getTime();
const diffMs = now - then;
const diffMin = Math.floor(diffMs / 60000);
const diffHour = Math.floor(diffMs / 3600000);
const diffDay = Math.floor(diffMs / 86400000);
if (diffMin < 1) return 'Just now';
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHour < 24) return `${diffHour}h ago`;
if (diffDay < 7) return `${diffDay}d ago`;
return new Date(timestamp).toLocaleDateString();
}
function getDotColor(activity: RecentActivity): string {
return TYPE_DOT_COLORS[activity.type] || 'bg-muted-foreground';
}
export function RecentActivityCard() { export function RecentActivityCard() {
const { t } = useTranslation(); const { t } = useTranslation();
const { recentActivity, isLoading } = useDashboard();
const activities = [
{ dotColor: 'bg-[var(--sumi-accent)]', textKey: 'dashboard.activity.newTrackAdded', timeKey: 'dashboard.activity.recently' },
{ dotColor: 'bg-[var(--sumi-sage)]', textKey: 'dashboard.activity.messageFrom', timeKey: 'dashboard.activity.recently', params: { user: 'alice' } },
{ dotColor: 'bg-[var(--sumi-vermillion)]', textKey: 'dashboard.activity.newFavoriteAdded', timeKey: 'dashboard.activity.recently' },
];
return ( return (
<div className="ink-card p-5 md:col-span-2 relative"> <div className="ink-card p-5 md:col-span-2 relative">
@ -17,7 +41,7 @@ export function RecentActivityCard() {
<div className="flex items-center justify-between mb-5"> <div className="flex items-center justify-between mb-5">
<h2 className="font-heading text-lg tracking-tight" style={{ fontWeight: 300 }}>{t('dashboard.recentActivity')}</h2> <h2 className="font-heading text-lg tracking-tight" style={{ fontWeight: 300 }}>{t('dashboard.recentActivity')}</h2>
<Link <Link
to="/library" to="/notifications"
className="text-[10px] text-muted-foreground/40 hover:text-foreground transition-colors tracking-[0.15em] uppercase font-heading" className="text-[10px] text-muted-foreground/40 hover:text-foreground transition-colors tracking-[0.15em] uppercase font-heading"
style={{ fontWeight: 300 }} style={{ fontWeight: 300 }}
> >
@ -29,21 +53,39 @@ export function RecentActivityCard() {
{t('dashboard.recentActivityDescription')} {t('dashboard.recentActivityDescription')}
</p> </p>
<div className="space-y-0"> {isLoading ? (
{activities.map((activity, i) => ( <div className="space-y-0">
<div key={i} className="flex items-center gap-4 py-3 border-b border-[var(--sumi-border-faint)] last:border-b-0"> {[...Array(3)].map((_, i) => (
<div className={`w-1.5 h-1.5 rounded-full ${activity.dotColor} shrink-0`} /> <div key={i} className="flex items-center gap-4 py-3 border-b border-[var(--sumi-border-faint)] last:border-b-0 animate-pulse">
<div className="flex-1 min-w-0"> <div className="w-1.5 h-1.5 rounded-full bg-muted/30 shrink-0" />
<p className="text-sm text-foreground/80 truncate font-heading" style={{ fontWeight: 300 }}> <div className="flex-1 min-w-0">
{activity.params ? t(activity.textKey, activity.params) : t(activity.textKey)} <div className="h-3.5 bg-muted/20 rounded-sm w-3/4" />
</p> </div>
<div className="h-3 bg-muted/20 rounded-sm w-12" />
</div> </div>
<span className="text-[10px] text-muted-foreground/30 shrink-0 font-heading" style={{ fontWeight: 300 }}> ))}
{t(activity.timeKey)} </div>
</span> ) : recentActivity.length === 0 ? (
</div> <p className="text-sm text-muted-foreground/40 text-center py-10 font-heading" style={{ fontWeight: 300 }}>
))} {t('dashboard.recentActivityDescription')}
</div> </p>
) : (
<div className="space-y-0">
{recentActivity.slice(0, 5).map((activity) => (
<div key={activity.id} className="flex items-center gap-4 py-3 border-b border-[var(--sumi-border-faint)] last:border-b-0">
<div className={`w-1.5 h-1.5 rounded-full ${getDotColor(activity)} shrink-0`} />
<div className="flex-1 min-w-0">
<p className="text-sm text-foreground/80 truncate font-heading" style={{ fontWeight: 300 }}>
{activity.title}
</p>
</div>
<span className="text-[10px] text-muted-foreground/30 shrink-0 font-heading" style={{ fontWeight: 300 }}>
{formatRelativeTime(activity.timestamp)}
</span>
</div>
))}
</div>
)}
</div> </div>
); );
} }

View file

@ -5,13 +5,13 @@ import { useDashboard } from '../hooks/useDashboard';
const STAT_META = [ const STAT_META = [
{ {
titleKey: 'dashboard.stats.tracksInLibrary', titleKey: 'dashboard.stats.tracksListened',
icon: Music, icon: Music,
kanji: '曲', kanji: '曲',
iconColor: 'text-primary', iconColor: 'text-primary',
}, },
{ {
titleKey: 'dashboard.stats.playlists', titleKey: 'dashboard.stats.favorites',
icon: Heart, icon: Heart,
kanji: '集', kanji: '集',
iconColor: 'text-destructive', iconColor: 'text-destructive',

View file

@ -70,8 +70,8 @@ function WelcomeBanner({ username }: { username: string }) {
const QUICK_ACTIONS = [ const QUICK_ACTIONS = [
{ icon: Upload, labelKey: 'dashboard.uploadTrack', path: '/library?action=upload', color: 'bg-primary/10 text-primary' }, { icon: Upload, labelKey: 'dashboard.uploadTrack', path: '/library?action=upload', color: 'bg-primary/10 text-primary' },
{ icon: ListMusic, labelKey: 'dashboard.createPlaylist', path: '/library', color: 'bg-success/10 text-success' }, { icon: ListMusic, labelKey: 'dashboard.createPlaylist', path: '/playlists', color: 'bg-success/10 text-success' },
{ icon: Search, labelKey: 'dashboard.discoverMusic', path: '/search', color: 'bg-warning/10 text-warning' }, { icon: Search, labelKey: 'dashboard.discoverMusic', path: '/discover', color: 'bg-warning/10 text-warning' },
{ icon: MessageSquare, labelKey: 'dashboard.openChat', path: '/chat', color: 'bg-info/10 text-info' }, { icon: MessageSquare, labelKey: 'dashboard.openChat', path: '/chat', color: 'bg-info/10 text-info' },
] as const; ] as const;

View file

@ -1,4 +1,5 @@
import { apiClient } from '@/services/api/client'; import { apiClient } from '@/services/api/client';
import { isCancel } from 'axios';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
// BE-PAGE-001: Dashboard service for fetching dashboard data // BE-PAGE-001: Dashboard service for fetching dashboard data
@ -102,6 +103,11 @@ export async function getDashboardData(
library_preview: dashboardData.library_preview, library_preview: dashboardData.library_preview,
}; };
} catch (error) { } catch (error) {
// Re-throw abort/cancel errors so React Query handles retries properly
if (isCancel(error) || (error instanceof DOMException && error.name === 'AbortError')) {
throw error;
}
logger.error('Failed to fetch dashboard data from aggregated endpoint', { logger.error('Failed to fetch dashboard data from aggregated endpoint', {
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined, stack: error instanceof Error ? error.stack : undefined,

View file

@ -7,6 +7,7 @@ import { useCallback, useEffect, useRef } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom'; import { useSearchParams, useNavigate } from 'react-router-dom';
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { usePlayerStore } from '@/features/player/store/playerStore'; import { usePlayerStore } from '@/features/player/store/playerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { ContentFadeIn } from '@/components/ui/ContentFadeIn'; import { ContentFadeIn } from '@/components/ui/ContentFadeIn';
import { TrackGrid } from '@/features/tracks/components/TrackGrid'; import { TrackGrid } from '@/features/tracks/components/TrackGrid';
import { TrackCardSkeleton } from '@/features/tracks/components/TrackCardSkeleton'; import { TrackCardSkeleton } from '@/features/tracks/components/TrackCardSkeleton';
@ -44,6 +45,7 @@ export function DiscoverPage() {
const play = usePlayerStore((s) => s.play); const play = usePlayerStore((s) => s.play);
const navigate = useNavigate(); const navigate = useNavigate();
const loadMoreRef = useRef<HTMLDivElement>(null); const loadMoreRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation();
const browseGenre = genreFromQuery; const browseGenre = genreFromQuery;
const browseTag = tagFromQuery; const browseTag = tagFromQuery;
@ -151,7 +153,7 @@ export function DiscoverPage() {
<ContentFadeIn className="min-h-layout-page pb-36"> <ContentFadeIn className="min-h-layout-page pb-36">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Music2 className="w-8 h-8 text-primary" /> <Music2 className="w-8 h-8 text-primary" />
<h1 className="text-2xl font-heading font-bold">Discover</h1> <h1 className="text-2xl font-heading font-bold">{t('discover.title')}</h1>
</div> </div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 mt-6"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 mt-6">
{Array.from({ length: 12 }).map((_, i) => ( {Array.from({ length: 12 }).map((_, i) => (
@ -166,6 +168,8 @@ export function DiscoverPage() {
); );
} }
const hasEditorialPlaylists = editorialData?.items && editorialData.items.length > 0;
return ( return (
<ContentFadeIn className="min-h-layout-page pb-36"> <ContentFadeIn className="min-h-layout-page pb-36">
<div className="space-y-8"> <div className="space-y-8">
@ -177,9 +181,10 @@ export function DiscoverPage() {
size="sm" size="sm"
onClick={goBack} onClick={goBack}
className="-ml-2 hover:bg-[var(--sumi-bg-hover)]" className="-ml-2 hover:bg-[var(--sumi-bg-hover)]"
aria-label={t('discover.back')}
> >
<ChevronLeft className="w-5 h-5" /> <ChevronLeft className="w-5 h-5" />
Back {t('discover.back')}
</Button> </Button>
) : null} ) : null}
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-primary/30 to-primary/10 flex items-center justify-center"> <div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-primary/30 to-primary/10 flex items-center justify-center">
@ -191,24 +196,25 @@ export function DiscoverPage() {
? genres?.find((g) => g.slug === browseGenre)?.name ?? browseGenre ? genres?.find((g) => g.slug === browseGenre)?.name ?? browseGenre
: browseTag : browseTag
? browseTag ? browseTag
: 'Discover'} : t('discover.title')}
</h1> </h1>
{showGenreList && ( {showGenreList && (
<p className="text-sm text-muted-foreground mt-0.5">Explore by genre, tag, or editorial playlist</p> <p className="text-sm text-muted-foreground mt-0.5">{t('discover.subtitle')}</p>
)} )}
</div> </div>
</div> </div>
{showGenreList && genres ? ( {showGenreList && genres ? (
<section className="space-y-4"> <section className="space-y-4" aria-label={t('discover.byGenre')}>
<h2 className="text-lg font-heading font-semibold tracking-tight"> <h2 className="text-lg font-heading font-semibold tracking-tight">
By Genre {t('discover.byGenre')}
</h2> </h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
{genres.map((g, i) => ( {genres.map((g, i) => (
<button <button
key={g.slug} key={g.slug}
onClick={() => handleGenreClick(g)} onClick={() => handleGenreClick(g)}
aria-label={t('discover.browseGenre', { genre: g.name })}
className={cn( className={cn(
'relative overflow-hidden rounded-2xl min-h-[7.5rem] p-4 pb-5 text-left', 'relative overflow-hidden rounded-2xl min-h-[7.5rem] p-4 pb-5 text-left',
'bg-gradient-to-br', 'bg-gradient-to-br',
@ -224,7 +230,7 @@ export function DiscoverPage() {
</span> </span>
{'count' in g && (g as Genre & { count?: number }).count != null && ( {'count' in g && (g as Genre & { count?: number }).count != null && (
<span className="relative z-10 block mt-1 text-xs text-white/60 font-medium"> <span className="relative z-10 block mt-1 text-xs text-white/60 font-medium">
{(g as Genre & { count?: number }).count} tracks {t('discover.trackCount', { count: (g as Genre & { count?: number }).count })}
</span> </span>
)} )}
{/* Decorative circles */} {/* Decorative circles */}
@ -239,10 +245,10 @@ export function DiscoverPage() {
) : null} ) : null}
{showGenreList ? ( {showGenreList ? (
<section className="space-y-4"> <section className="space-y-4" aria-label={t('discover.editorialPlaylists')}>
<div className="section-divider my-2" /> <div className="section-divider my-2" />
<h2 className="text-lg font-heading font-semibold tracking-tight"> <h2 className="text-lg font-heading font-semibold tracking-tight">
Editorial Playlists {t('discover.editorialPlaylists')}
</h2> </h2>
{editorialLoading ? ( {editorialLoading ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
@ -250,7 +256,7 @@ export function DiscoverPage() {
<PlaylistCardSkeleton key={i} /> <PlaylistCardSkeleton key={i} />
))} ))}
</div> </div>
) : editorialData?.items && editorialData.items.length > 0 ? ( ) : hasEditorialPlaylists ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{editorialData.items.map((pl) => ( {editorialData.items.map((pl) => (
<PlaylistCard <PlaylistCard
@ -269,7 +275,11 @@ export function DiscoverPage() {
/> />
))} ))}
</div> </div>
) : null} ) : (
<p className="text-sm text-muted-foreground py-4">
{t('discover.noEditorialPlaylists')}
</p>
)}
</section> </section>
) : null} ) : null}
@ -287,7 +297,7 @@ export function DiscoverPage() {
<> <>
<TrackGrid <TrackGrid
tracks={tracks} tracks={tracks}
emptyMessage="No tracks in this genre" emptyMessage={t('discover.noTracksInGenre')}
onTrackPlay={handlePlay} onTrackPlay={handlePlay}
onTrackClick={handleTrackClick} onTrackClick={handleTrackClick}
gap="md" gap="md"

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { within, userEvent, expect } from '@storybook/test'; import { within, userEvent, expect } from 'storybook/test';
import { withRouter } from '../../../stories/decorators'; import { withRouter } from '../../../stories/decorators';
import ServerErrorPage from './ServerErrorPage'; import ServerErrorPage from './ServerErrorPage';

View file

@ -8,9 +8,11 @@ import { Link } from 'react-router-dom';
import { Avatar } from '@/components/ui/avatar'; import { Avatar } from '@/components/ui/avatar';
import { getSuggestions } from '@/features/profile/services/profileService'; import { getSuggestions } from '@/features/profile/services/profileService';
import { FollowButton } from '@/features/profile/components/FollowButton'; import { FollowButton } from '@/features/profile/components/FollowButton';
import { useTranslation } from '@/hooks/useTranslation';
import { UserPlus, Loader2, ChevronRight } from 'lucide-react'; import { UserPlus, Loader2, ChevronRight } from 'lucide-react';
export function SuggestionsWidget() { export function SuggestionsWidget() {
const { t } = useTranslation();
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['suggestions'], queryKey: ['suggestions'],
queryFn: () => getSuggestions(5), queryFn: () => getSuggestions(5),
@ -23,7 +25,7 @@ export function SuggestionsWidget() {
<div className="p-4 pb-3"> <div className="p-4 pb-3">
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2"> <h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
<UserPlus className="w-4 h-4 text-primary" /> <UserPlus className="w-4 h-4 text-primary" />
Suggested Accounts {t('feed.suggestedAccounts')}
</h3> </h3>
</div> </div>
<div className="flex items-center justify-center py-10 px-4"> <div className="flex items-center justify-center py-10 px-4">
@ -64,7 +66,7 @@ export function SuggestionsWidget() {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground truncate">@{user.username}</p> <p className="text-sm font-medium text-foreground truncate">@{user.username}</p>
<p className="text-xs text-muted-foreground/80"> <p className="text-xs text-muted-foreground/80">
{(user.followers_count ?? 0).toLocaleString()} followers {t('feed.followers', { count: user.followers_count ?? 0 })}
</p> </p>
</div> </div>
</Link> </Link>
@ -79,7 +81,7 @@ export function SuggestionsWidget() {
to="/social" to="/social"
className="text-xs font-medium text-muted-foreground hover:text-primary transition-colors duration-[var(--sumi-duration-fast)] flex items-center gap-1 group/link" className="text-xs font-medium text-muted-foreground hover:text-primary transition-colors duration-[var(--sumi-duration-fast)] flex items-center gap-1 group/link"
> >
See all {t('feed.seeAll')}
<ChevronRight className="w-3 h-3 group-hover/link:translate-x-0.5 transition-transform duration-[var(--sumi-duration-fast)]" /> <ChevronRight className="w-3 h-3 group-hover/link:translate-x-0.5 transition-transform duration-[var(--sumi-duration-fast)]" />
</Link> </Link>
</div> </div>

View file

@ -14,7 +14,7 @@ import { ErrorDisplay } from '@/components/ui/ErrorDisplay';
import { SuggestionsWidget } from '../components/SuggestionsWidget'; import { SuggestionsWidget } from '../components/SuggestionsWidget';
import { feedService } from '@/services/feedService'; import { feedService } from '@/services/feedService';
import { Music2, Loader2, Disc3, Sparkles } from 'lucide-react'; import { Music2, Loader2, Disc3, Sparkles } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from '@/hooks/useTranslation';
export function FeedPage() { export function FeedPage() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -155,7 +155,7 @@ export function FeedPage() {
</h2> </h2>
<TrackGrid <TrackGrid
tracks={tracks} tracks={tracks}
emptyMessage="No new tracks" emptyMessage={t('feed.noNewTracks')}
onTrackPlay={handlePlay} onTrackPlay={handlePlay}
onTrackClick={handleTrackClick} onTrackClick={handleTrackClick}
gap="md" gap="md"

View file

@ -1,4 +1,4 @@
import { useState, useRef } from 'react'; import { useState, useRef, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { motion, useInView } from 'framer-motion'; import { motion, useInView } from 'framer-motion';
import { import {
@ -14,6 +14,7 @@ import {
Globe, Globe,
Heart, Heart,
} from 'lucide-react'; } from 'lucide-react';
import { useTranslation } from '@/hooks/useTranslation';
/* /*
TALAS LANDING PAGE Pre-launch TALAS LANDING PAGE Pre-launch
@ -57,13 +58,20 @@ function Section({ children, className = '', id }: { children: React.ReactNode;
} }
export default function LandingPage() { export default function LandingPage() {
const { t } = useTranslation();
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle'); const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [errorMsg, setErrorMsg] = useState(''); const [errorMsg, setErrorMsg] = useState('');
// BUG-09 fix: Set page-specific document.title
useEffect(() => {
document.title = t('landing.pageTitle');
}, [t]);
const handleSubscribe = async (e: React.FormEvent) => { const handleSubscribe = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!email || !email.includes('@')) return; // BUG-13 fix: Better email validation
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return;
setStatus('loading'); setStatus('loading');
try { try {
const res = await fetch('/api/v1/newsletter/subscribe', { const res = await fetch('/api/v1/newsletter/subscribe', {
@ -73,19 +81,71 @@ export default function LandingPage() {
}); });
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => null); const data = await res.json().catch(() => null);
throw new Error(data?.error?.message || 'Subscription failed'); // BUG-02 fix: Use i18n for error messages instead of hardcoded English
throw new Error(data?.error?.message || t('landing.form.errorSubscription'));
} }
setStatus('success'); setStatus('success');
setEmail(''); setEmail('');
} catch (err) { } catch (err) {
setStatus('error'); setStatus('error');
setErrorMsg(err instanceof Error ? err.message : 'An error occurred'); setErrorMsg(err instanceof Error ? err.message : t('landing.form.errorGeneric'));
setTimeout(() => setStatus('idle'), 4000); setTimeout(() => setStatus('idle'), 4000);
} }
}; };
const valueCards = [
{
icon: Wrench,
kanji: '開',
title: t('landing.values.card1.title'),
desc: t('landing.values.card1.desc'),
accent: 'var(--sumi-accent)',
},
{
icon: Shield,
kanji: '守',
title: t('landing.values.card2.title'),
desc: t('landing.values.card2.desc'),
accent: 'var(--sumi-sage)',
},
{
icon: Users,
kanji: '結',
title: t('landing.values.card3.title'),
desc: t('landing.values.card3.desc'),
accent: 'var(--sumi-kin)',
},
];
const productFeatures = [
{ icon: Eye, text: t('landing.product.feat1') },
{ icon: Wrench, text: t('landing.product.feat2') },
{ icon: Globe, text: t('landing.product.feat3') },
{ icon: Lock, text: t('landing.product.feat4') },
];
const platformFeatures = [
{ icon: Music, label: t('landing.platform.streaming') },
{ icon: Users, label: t('landing.platform.community') },
{ icon: Heart, label: t('landing.platform.marketplace') },
{ icon: Lock, label: t('landing.platform.privacy') },
{ icon: Globe, label: t('landing.platform.openSource') },
{ icon: Shield, label: t('landing.platform.zeroTracking') },
];
const footerLinks = [
{ label: t('landing.footer.openSource'), href: 'https://github.com/talas-audio', external: true },
{ label: t('landing.footer.privacy'), href: '/privacy', external: false },
{ label: t('landing.footer.contact'), href: 'mailto:contact@talas.audio', external: false },
];
return ( return (
<div className="min-h-screen bg-[var(--sumi-bg-void)] text-[var(--sumi-text-primary)] overflow-x-hidden"> // BUG-03 fix: Use <main> as the root landmark element
<main
id="main-content"
className="min-h-screen bg-[var(--sumi-bg-void)] text-[var(--sumi-text-primary)] overflow-x-hidden"
data-testid="launch-page"
>
{/* ═══ ATMOSPHERE — Ink wash background ═══ */} {/* ═══ ATMOSPHERE — Ink wash background ═══ */}
<div className="fixed inset-0 pointer-events-none" aria-hidden="true"> <div className="fixed inset-0 pointer-events-none" aria-hidden="true">
<div <div
@ -112,9 +172,14 @@ export default function LandingPage() {
</div> </div>
{/* ═══ NAVIGATION ═══ */} {/* ═══ NAVIGATION ═══ */}
<nav className="fixed top-0 inset-x-0 z-50 border-b border-[var(--sumi-border-faint)] bg-[var(--sumi-bg-void)]/80 backdrop-blur-xl"> {/* BUG-10 fix: Added aria-label. BUG-15 fix: Added flex-wrap and overflow handling for mobile */}
<div className="max-w-[1200px] mx-auto px-6 h-14 flex items-center justify-between"> <nav
<div className="flex items-center gap-3"> className="fixed top-0 inset-x-0 z-50 border-b border-[var(--sumi-border-faint)] bg-[var(--sumi-bg-void)]/80 backdrop-blur-xl"
aria-label={t('landing.nav.ariaLabel')}
data-testid="launch-nav"
>
<div className="max-w-[1200px] mx-auto px-4 sm:px-6 h-14 flex items-center justify-between">
<div className="flex items-center gap-3 shrink-0">
<div className="h-8 w-8 rounded-sm bg-[var(--sumi-accent)] flex items-center justify-center hanko-seal" style={{ transform: 'rotate(-2deg)' }}> <div className="h-8 w-8 rounded-sm bg-[var(--sumi-accent)] flex items-center justify-center hanko-seal" style={{ transform: 'rotate(-2deg)' }}>
<span className="text-[var(--sumi-bg-base)] font-[var(--sumi-font-heading)] text-sm font-semibold relative z-10">T</span> <span className="text-[var(--sumi-bg-base)] font-[var(--sumi-font-heading)] text-sm font-semibold relative z-10">T</span>
</div> </div>
@ -125,34 +190,35 @@ export default function LandingPage() {
TALAS TALAS
</span> </span>
</div> </div>
<div className="flex items-center gap-6"> <div className="flex items-center gap-3 sm:gap-6 overflow-hidden">
<a <a
href="#product" href="#product"
className="text-xs tracking-[0.15em] text-[var(--sumi-text-tertiary)] hover:text-[var(--sumi-text-primary)] transition-colors" className="hidden sm:inline text-xs tracking-[0.15em] text-[var(--sumi-text-tertiary)] hover:text-[var(--sumi-text-primary)] transition-colors"
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }} style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
> >
PRODUIT {t('landing.nav.product')}
</a> </a>
<a <a
href="#platform" href="#platform"
className="text-xs tracking-[0.15em] text-[var(--sumi-text-tertiary)] hover:text-[var(--sumi-text-primary)] transition-colors" className="hidden sm:inline text-xs tracking-[0.15em] text-[var(--sumi-text-tertiary)] hover:text-[var(--sumi-text-primary)] transition-colors"
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }} style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
> >
PLATEFORME {t('landing.nav.platform')}
</a> </a>
<Link <Link
to="/login" to="/login"
className="text-xs tracking-[0.1em] px-4 py-1.5 border border-[var(--sumi-border-strong)] rounded-sm text-[var(--sumi-text-secondary)] hover:text-[var(--sumi-text-primary)] hover:border-[var(--sumi-accent)]/40 transition-all" className="text-xs tracking-[0.1em] px-4 py-1.5 border border-[var(--sumi-border-strong)] rounded-sm text-[var(--sumi-text-secondary)] hover:text-[var(--sumi-text-primary)] hover:border-[var(--sumi-accent)]/40 transition-all whitespace-nowrap"
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }} style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
data-testid="launch-login-link"
> >
CONNEXION {t('landing.nav.login')}
</Link> </Link>
</div> </div>
</div> </div>
</nav> </nav>
{/* ═══ HERO ═══ */} {/* ═══ HERO ═══ */}
<header className="relative z-10 min-h-screen flex flex-col items-center justify-center px-6 pt-14"> <header className="relative z-10 min-h-screen flex flex-col items-center justify-center px-6 pt-14" data-testid="launch-hero">
<motion.div <motion.div
className="max-w-[800px] mx-auto text-center" className="max-w-[800px] mx-auto text-center"
initial="hidden" initial="hidden"
@ -188,9 +254,9 @@ export default function LandingPage() {
className="text-base sm:text-lg text-[var(--sumi-text-secondary)] leading-relaxed max-w-[550px] mx-auto mb-4" className="text-base sm:text-lg text-[var(--sumi-text-secondary)] leading-relaxed max-w-[550px] mx-auto mb-4"
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }} style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
> >
Matériel audio professionnel ouvert, réparable, transparent. {t('landing.hero.tagline1')}
<br /> <br />
Plateforme musicale éthique sans tracking, sans algorithme. {t('landing.hero.tagline2')}
</motion.p> </motion.p>
<motion.p <motion.p
@ -199,7 +265,7 @@ export default function LandingPage() {
className="text-xs tracking-[0.25em] text-[var(--sumi-text-tertiary)] uppercase mb-12" className="text-xs tracking-[0.25em] text-[var(--sumi-text-tertiary)] uppercase mb-12"
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }} style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
> >
Lancement bientôt Rejoins les premiers {t('landing.hero.cta')}
</motion.p> </motion.p>
{/* Email capture — hero */} {/* Email capture — hero */}
@ -208,12 +274,13 @@ export default function LandingPage() {
custom={4} custom={4}
onSubmit={handleSubscribe} onSubmit={handleSubscribe}
className="flex flex-col sm:flex-row items-center gap-3 max-w-[460px] mx-auto" className="flex flex-col sm:flex-row items-center gap-3 max-w-[460px] mx-auto"
data-testid="launch-hero-form"
> >
{status === 'success' ? ( {status === 'success' ? (
<div className="flex items-center gap-2 text-[var(--sumi-sage)] text-sm py-3"> <div className="flex items-center gap-2 text-[var(--sumi-sage)] text-sm py-3" data-testid="launch-hero-success">
<Check size={16} /> <Check size={16} />
<span style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}> <span style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}>
Inscription confirmée. À bientôt. {t('landing.form.successHero')}
</span> </span>
</div> </div>
) : ( ) : (
@ -222,23 +289,29 @@ export default function LandingPage() {
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="ton@email.com" placeholder={t('landing.hero.placeholder')}
required required
aria-label="Adresse email" aria-label={t('landing.notify.ariaLabel')}
data-testid="launch-hero-email"
className="flex-1 w-full sm:w-auto px-4 py-2.5 bg-[var(--sumi-surface-card)] border border-[var(--sumi-border-default)] rounded-sm text-sm text-[var(--sumi-text-primary)] placeholder:text-[var(--sumi-text-disabled)] focus:outline-none focus:border-[var(--sumi-accent)]/50 focus:shadow-[var(--sumi-shadow-glow)] transition-all" className="flex-1 w-full sm:w-auto px-4 py-2.5 bg-[var(--sumi-surface-card)] border border-[var(--sumi-border-default)] rounded-sm text-sm text-[var(--sumi-text-primary)] placeholder:text-[var(--sumi-text-disabled)] focus:outline-none focus:border-[var(--sumi-accent)]/50 focus:shadow-[var(--sumi-shadow-glow)] transition-all"
style={{ fontFamily: 'var(--sumi-font-body)' }} style={{ fontFamily: 'var(--sumi-font-body)' }}
/> />
<button <button
type="submit" type="submit"
disabled={status === 'loading'} disabled={status === 'loading'}
data-testid="launch-hero-submit"
className="w-full sm:w-auto px-6 py-2.5 bg-[var(--sumi-accent)] text-[var(--sumi-text-inverse)] rounded-sm text-sm tracking-[0.1em] hover:bg-[var(--sumi-accent-hover)] disabled:opacity-50 transition-colors flex items-center justify-center gap-2" className="w-full sm:w-auto px-6 py-2.5 bg-[var(--sumi-accent)] text-[var(--sumi-text-inverse)] rounded-sm text-sm tracking-[0.1em] hover:bg-[var(--sumi-accent-hover)] disabled:opacity-50 transition-colors flex items-center justify-center gap-2"
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 400 }} style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 400 }}
> >
{status === 'loading' ? ( {status === 'loading' ? (
<span className="h-4 w-4 border border-current border-t-transparent rounded-full animate-spin" /> // BUG-11 fix: Added role="status" and sr-only text for spinner
<span role="status">
<span className="h-4 w-4 border border-current border-t-transparent rounded-full animate-spin inline-block" aria-hidden="true" />
<span className="sr-only">{t('landing.form.loadingAriaLabel')}</span>
</span>
) : ( ) : (
<> <>
REJOINDRE {t('landing.hero.submit')}
<ArrowRight size={14} /> <ArrowRight size={14} />
</> </>
)} )}
@ -248,7 +321,7 @@ export default function LandingPage() {
</motion.form> </motion.form>
{status === 'error' && ( {status === 'error' && (
<p className="text-xs text-[var(--sumi-vermillion)] mt-2">{errorMsg}</p> <p className="text-xs text-[var(--sumi-vermillion)] mt-2" data-testid="launch-error-message">{errorMsg}</p>
)} )}
{/* Scroll hint */} {/* Scroll hint */}
@ -259,62 +332,40 @@ export default function LandingPage() {
> >
<div className="w-px h-12 bg-gradient-to-b from-[var(--sumi-border-strong)] to-transparent mx-auto mb-2" /> <div className="w-px h-12 bg-gradient-to-b from-[var(--sumi-border-strong)] to-transparent mx-auto mb-2" />
<span className="text-[10px] tracking-[0.3em] text-[var(--sumi-text-disabled)]" style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}> <span className="text-[10px] tracking-[0.3em] text-[var(--sumi-text-disabled)]" style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}>
DÉCOUVRIR {t('landing.hero.discover')}
</span> </span>
</motion.div> </motion.div>
</motion.div> </motion.div>
</header> </header>
{/* ═══ VALUES — Three pillars ═══ */} {/* ═══ VALUES — Three pillars ═══ */}
<Section className="relative z-10 py-32 px-6"> <Section className="relative z-10 py-32 px-6" data-testid="launch-values">
<div className="max-w-[1000px] mx-auto"> <div className="max-w-[1000px] mx-auto">
<motion.div variants={inkReveal} custom={0} className="text-center mb-20"> <motion.div variants={inkReveal} custom={0} className="text-center mb-20">
<span className="text-[10px] tracking-[0.3em] text-[var(--sumi-kin)] uppercase" style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}> <span className="text-[10px] tracking-[0.3em] text-[var(--sumi-kin)] uppercase" style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}>
{t('landing.values.kicker')}
</span> </span>
<h2 <h2
className="text-3xl sm:text-4xl mt-3 tracking-[0.05em]" className="text-3xl sm:text-4xl mt-3 tracking-[0.05em]"
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 200 }} style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 200 }}
> >
Trois engagements {t('landing.values.title')}
</h2> </h2>
<div className="w-16 h-px bg-gradient-to-r from-transparent via-[var(--sumi-kin)]/30 to-transparent mx-auto mt-6" /> <div className="w-16 h-px bg-gradient-to-r from-transparent via-[var(--sumi-kin)]/30 to-transparent mx-auto mt-6" />
</motion.div> </motion.div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8"> <div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{[ {valueCards.map((card, i) => (
{
icon: Wrench,
kanji: '開',
title: 'Hardware Ouvert',
desc: 'Schémas publiés sous licence CERN-OHL. Tu peux construire, réparer et améliorer chaque composant. Pas d\'obsolescence programmée.',
accent: 'var(--sumi-accent)',
},
{
icon: Shield,
kanji: '守',
title: 'Plateforme Éthique',
desc: 'Zéro tracking comportemental. Zéro algorithme de manipulation. Flux chronologique. Données privées. Code open-source (AGPL-3.0).',
accent: 'var(--sumi-sage)',
},
{
icon: Users,
kanji: '結',
title: 'Communauté Artiste',
desc: 'Streaming, marketplace, chat en temps réel, playlists collaboratives. Rémunération transparente. Les artistes contrôlent leur musique.',
accent: 'var(--sumi-kin)',
},
].map((card, i) => (
<motion.div <motion.div
key={card.title} key={card.title}
variants={inkReveal} variants={inkReveal}
custom={i + 1} custom={i + 1}
className="ink-card p-8 bg-[var(--sumi-surface-card)]/60 backdrop-blur-sm border border-[var(--sumi-border-faint)] group hover:border-[var(--sumi-border-strong)] transition-all duration-500" className="ink-card p-8 bg-[var(--sumi-surface-card)]/60 backdrop-blur-sm border border-[var(--sumi-border-faint)] group hover:border-[var(--sumi-border-strong)] transition-all duration-500"
> >
{/* Ghost kanji in card */}
<span <span
className="absolute -right-2 -top-4 text-[100px] opacity-[0.025] pointer-events-none select-none" className="absolute -right-2 -top-4 text-[100px] opacity-[0.025] pointer-events-none select-none"
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 200 }} style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 200 }}
aria-hidden="true"
> >
{card.kanji} {card.kanji}
</span> </span>
@ -346,7 +397,7 @@ export default function LandingPage() {
</Section> </Section>
{/* ═══ PRODUCT TEASER — Condenser microphone ═══ */} {/* ═══ PRODUCT TEASER — Condenser microphone ═══ */}
<Section className="relative z-10 py-32 px-6" id="product"> <Section className="relative z-10 py-32 px-6" id="product" data-testid="launch-product">
<div className="max-w-[1000px] mx-auto"> <div className="max-w-[1000px] mx-auto">
<div className="grid grid-cols-1 md:grid-cols-2 gap-16 items-center"> <div className="grid grid-cols-1 md:grid-cols-2 gap-16 items-center">
{/* Microphone illustration — abstract */} {/* Microphone illustration — abstract */}
@ -356,7 +407,6 @@ export default function LandingPage() {
className="relative flex items-center justify-center" className="relative flex items-center justify-center"
> >
<div className="relative w-[280px] h-[380px]"> <div className="relative w-[280px] h-[380px]">
{/* Ink wash backdrop */}
<div <div
className="absolute inset-0 rounded-sm" className="absolute inset-0 rounded-sm"
style={{ style={{
@ -367,16 +417,13 @@ export default function LandingPage() {
border: '1px solid var(--sumi-border-faint)', border: '1px solid var(--sumi-border-faint)',
}} }}
/> />
{/* Microphone silhouette */}
<div className="absolute inset-0 flex flex-col items-center justify-center"> <div className="absolute inset-0 flex flex-col items-center justify-center">
<Mic size={80} strokeWidth={0.8} className="text-[var(--sumi-text-tertiary)] mb-6" /> <Mic size={80} strokeWidth={0.8} className="text-[var(--sumi-text-tertiary)] mb-6" />
<div className="w-px h-20 bg-gradient-to-b from-[var(--sumi-text-tertiary)]/30 to-transparent" /> <div className="w-px h-20 bg-gradient-to-b from-[var(--sumi-text-tertiary)]/30 to-transparent" />
<div className="w-12 h-1 rounded-full bg-[var(--sumi-text-tertiary)]/15 mt-1" /> <div className="w-12 h-1 rounded-full bg-[var(--sumi-text-tertiary)]/15 mt-1" />
</div> </div>
{/* Gold accent corner */}
<div className="absolute top-4 right-4 w-8 h-px bg-[var(--sumi-kin)]/30" /> <div className="absolute top-4 right-4 w-8 h-px bg-[var(--sumi-kin)]/30" />
<div className="absolute top-4 right-4 w-px h-8 bg-[var(--sumi-kin)]/30" /> <div className="absolute top-4 right-4 w-px h-8 bg-[var(--sumi-kin)]/30" />
{/* Seal */}
<div className="absolute bottom-6 right-6 h-10 w-10 rounded-sm bg-[var(--sumi-accent)]/10 border border-[var(--sumi-accent)]/20 flex items-center justify-center"> <div className="absolute bottom-6 right-6 h-10 w-10 rounded-sm bg-[var(--sumi-accent)]/10 border border-[var(--sumi-accent)]/20 flex items-center justify-center">
<span className="text-[var(--sumi-accent)] text-xs" style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 500 }}>T</span> <span className="text-[var(--sumi-accent)] text-xs" style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 500 }}>T</span>
</div> </div>
@ -391,7 +438,7 @@ export default function LandingPage() {
className="text-[10px] tracking-[0.3em] text-[var(--sumi-kin)] uppercase" className="text-[10px] tracking-[0.3em] text-[var(--sumi-kin)] uppercase"
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }} style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
> >
Premier produit {t('landing.product.kicker')}
</motion.span> </motion.span>
<motion.h2 <motion.h2
@ -400,29 +447,23 @@ export default function LandingPage() {
className="text-3xl sm:text-4xl mt-3 mb-2 tracking-[0.03em]" className="text-3xl sm:text-4xl mt-3 mb-2 tracking-[0.03em]"
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 200 }} style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 200 }}
> >
Microphone Condensateur {t('landing.product.title')}
</motion.h2> </motion.h2>
<motion.div variants={brushStroke} className="w-20 h-px bg-gradient-to-r from-[var(--sumi-accent)]/40 to-transparent mb-8" /> <motion.div variants={brushStroke} className="w-20 h-px bg-gradient-to-r from-[var(--sumi-accent)]/40 to-transparent mb-8" />
{/* BUG-06 fix: Removed duplicate text, use single i18n key */}
<motion.p <motion.p
variants={inkReveal} variants={inkReveal}
custom={2} custom={2}
className="text-sm text-[var(--sumi-text-secondary)] leading-relaxed mb-8" className="text-sm text-[var(--sumi-text-secondary)] leading-relaxed mb-8"
style={{ fontFamily: 'var(--sumi-font-body)' }} style={{ fontFamily: 'var(--sumi-font-body)' }}
> >
Large diaphragme. Préampli OPA1642. Corps aluminium usiné. {t('landing.product.desc')}
Schémas publiés, composants standards, guide de réparation inclus dans la boîte.
Garantie 5 ans. Composants standards. Guide de réparation dans la boîte.
</motion.p> </motion.p>
<motion.ul variants={inkReveal} custom={3} className="space-y-3 mb-10"> <motion.ul variants={inkReveal} custom={3} className="space-y-3 mb-10">
{[ {productFeatures.map((item) => (
{ icon: Eye, text: 'Schémas KiCAD publiés — CERN-OHL-W' },
{ icon: Wrench, text: 'Réparable — pas de colle, composants standards' },
{ icon: Globe, text: 'Fabriqué en France — sourcing documenté' },
{ icon: Lock, text: '~150 € — transparence totale des coûts' },
].map((item) => (
<li key={item.text} className="flex items-start gap-3 text-sm text-[var(--sumi-text-secondary)]"> <li key={item.text} className="flex items-start gap-3 text-sm text-[var(--sumi-text-secondary)]">
<item.icon size={15} className="text-[var(--sumi-accent)] mt-0.5 shrink-0" strokeWidth={1.5} /> <item.icon size={15} className="text-[var(--sumi-accent)] mt-0.5 shrink-0" strokeWidth={1.5} />
<span style={{ fontFamily: 'var(--sumi-font-body)' }}>{item.text}</span> <span style={{ fontFamily: 'var(--sumi-font-body)' }}>{item.text}</span>
@ -436,7 +477,7 @@ export default function LandingPage() {
className="inline-flex items-center gap-2 text-xs tracking-[0.15em] text-[var(--sumi-accent)] hover:text-[var(--sumi-accent-hover)] transition-colors" className="inline-flex items-center gap-2 text-xs tracking-[0.15em] text-[var(--sumi-accent)] hover:text-[var(--sumi-accent-hover)] transition-colors"
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 400 }} style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 400 }}
> >
ÊTRE NOTIFIÉ DU LANCEMENT {t('landing.product.cta')}
<ArrowRight size={13} /> <ArrowRight size={13} />
</a> </a>
</motion.div> </motion.div>
@ -446,7 +487,7 @@ export default function LandingPage() {
</Section> </Section>
{/* ═══ PLATFORM TEASER — Veza ═══ */} {/* ═══ PLATFORM TEASER — Veza ═══ */}
<Section className="relative z-10 py-32 px-6" id="platform"> <Section className="relative z-10 py-32 px-6" id="platform" data-testid="launch-platform">
<div className="max-w-[1000px] mx-auto text-center"> <div className="max-w-[1000px] mx-auto text-center">
<motion.span <motion.span
variants={inkReveal} variants={inkReveal}
@ -454,7 +495,7 @@ export default function LandingPage() {
className="text-[10px] tracking-[0.3em] text-[var(--sumi-kin)] uppercase" className="text-[10px] tracking-[0.3em] text-[var(--sumi-kin)] uppercase"
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }} style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
> >
La plateforme {t('landing.platform.kicker')}
</motion.span> </motion.span>
<motion.h2 <motion.h2
@ -474,18 +515,11 @@ export default function LandingPage() {
className="text-xs tracking-[0.2em] text-[var(--sumi-text-tertiary)] mb-12" className="text-xs tracking-[0.2em] text-[var(--sumi-text-tertiary)] mb-12"
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }} style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
> >
STREAMING DU MICRO À L'AUDITEUR {t('landing.platform.subtitle')}
</motion.p> </motion.p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-6 max-w-[700px] mx-auto mb-16"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-6 max-w-[700px] mx-auto mb-16">
{[ {platformFeatures.map((feat, i) => (
{ icon: Music, label: 'Streaming HLS' },
{ icon: Users, label: 'Communauté' },
{ icon: Heart, label: 'Marketplace' },
{ icon: Lock, label: 'Vie privée' },
{ icon: Globe, label: 'Open source' },
{ icon: Shield, label: 'Zéro tracking' },
].map((feat, i) => (
<motion.div <motion.div
key={feat.label} key={feat.label}
variants={inkReveal} variants={inkReveal}
@ -509,15 +543,13 @@ export default function LandingPage() {
className="text-sm text-[var(--sumi-text-tertiary)] max-w-[500px] mx-auto leading-relaxed" className="text-sm text-[var(--sumi-text-tertiary)] max-w-[500px] mx-auto leading-relaxed"
style={{ fontFamily: 'var(--sumi-font-body)' }} style={{ fontFamily: 'var(--sumi-font-body)' }}
> >
435 000 lignes de code. Audit de sécurité externe. 34 suites de tests. {t('landing.platform.stats')}
Backend Go + Stream server Rust + Frontend React.
Auto-hébergé. Pas de cloud. Pas de VC.
</motion.p> </motion.p>
</div> </div>
</Section> </Section>
{/* ═══ EMAIL CAPTURE — Bottom CTA ═══ */} {/* ═══ EMAIL CAPTURE — Bottom CTA ═══ */}
<Section className="relative z-10 py-32 px-6" id="notify"> <Section className="relative z-10 py-32 px-6" id="notify" data-testid="launch-notify">
<div className="max-w-[600px] mx-auto text-center"> <div className="max-w-[600px] mx-auto text-center">
<motion.span <motion.span
variants={inkReveal} variants={inkReveal}
@ -534,7 +566,7 @@ export default function LandingPage() {
className="text-2xl sm:text-3xl mb-3 tracking-[0.05em]" className="text-2xl sm:text-3xl mb-3 tracking-[0.05em]"
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 200 }} style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 200 }}
> >
Rejoins les premiers {t('landing.notify.title')}
</motion.h2> </motion.h2>
<motion.div variants={brushStroke} className="w-16 h-px bg-gradient-to-r from-transparent via-[var(--sumi-kin)]/30 to-transparent mx-auto mb-6" /> <motion.div variants={brushStroke} className="w-16 h-px bg-gradient-to-r from-transparent via-[var(--sumi-kin)]/30 to-transparent mx-auto mb-6" />
@ -545,8 +577,7 @@ export default function LandingPage() {
className="text-sm text-[var(--sumi-text-secondary)] mb-10 leading-relaxed" className="text-sm text-[var(--sumi-text-secondary)] mb-10 leading-relaxed"
style={{ fontFamily: 'var(--sumi-font-body)' }} style={{ fontFamily: 'var(--sumi-font-body)' }}
> >
Inscris-toi pour être notifié du lancement. {t('landing.notify.desc')}
Pas de spam un seul email le jour J.
</motion.p> </motion.p>
<motion.form <motion.form
@ -554,12 +585,14 @@ export default function LandingPage() {
custom={2} custom={2}
onSubmit={handleSubscribe} onSubmit={handleSubscribe}
className="flex flex-col sm:flex-row items-center gap-3 max-w-[460px] mx-auto" className="flex flex-col sm:flex-row items-center gap-3 max-w-[460px] mx-auto"
data-testid="launch-notify-form"
> >
{status === 'success' ? ( {status === 'success' ? (
<div className="flex items-center gap-2 text-[var(--sumi-sage)] text-sm py-3"> // BUG-12 fix: Use consistent success message from i18n
<div className="flex items-center gap-2 text-[var(--sumi-sage)] text-sm py-3" data-testid="launch-notify-success">
<Check size={16} /> <Check size={16} />
<span style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}> <span style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}>
C'est noté. Merci. {t('landing.form.successCta')}
</span> </span>
</div> </div>
) : ( ) : (
@ -568,23 +601,29 @@ export default function LandingPage() {
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="ton@email.com" placeholder={t('landing.notify.placeholder')}
required required
aria-label="Adresse email pour notification de lancement" aria-label={t('landing.notify.ariaLabel')}
data-testid="launch-notify-email"
className="flex-1 w-full sm:w-auto px-4 py-2.5 bg-[var(--sumi-surface-card)] border border-[var(--sumi-border-default)] rounded-sm text-sm text-[var(--sumi-text-primary)] placeholder:text-[var(--sumi-text-disabled)] focus:outline-none focus:border-[var(--sumi-accent)]/50 focus:shadow-[var(--sumi-shadow-glow)] transition-all" className="flex-1 w-full sm:w-auto px-4 py-2.5 bg-[var(--sumi-surface-card)] border border-[var(--sumi-border-default)] rounded-sm text-sm text-[var(--sumi-text-primary)] placeholder:text-[var(--sumi-text-disabled)] focus:outline-none focus:border-[var(--sumi-accent)]/50 focus:shadow-[var(--sumi-shadow-glow)] transition-all"
style={{ fontFamily: 'var(--sumi-font-body)' }} style={{ fontFamily: 'var(--sumi-font-body)' }}
/> />
<button <button
type="submit" type="submit"
disabled={status === 'loading'} disabled={status === 'loading'}
data-testid="launch-notify-submit"
className="w-full sm:w-auto px-6 py-2.5 bg-[var(--sumi-accent)] text-[var(--sumi-text-inverse)] rounded-sm text-sm tracking-[0.1em] hover:bg-[var(--sumi-accent-hover)] disabled:opacity-50 transition-colors flex items-center justify-center gap-2" className="w-full sm:w-auto px-6 py-2.5 bg-[var(--sumi-accent)] text-[var(--sumi-text-inverse)] rounded-sm text-sm tracking-[0.1em] hover:bg-[var(--sumi-accent-hover)] disabled:opacity-50 transition-colors flex items-center justify-center gap-2"
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 400 }} style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 400 }}
> >
{status === 'loading' ? ( {status === 'loading' ? (
<span className="h-4 w-4 border border-current border-t-transparent rounded-full animate-spin" /> <span role="status">
<span className="h-4 w-4 border border-current border-t-transparent rounded-full animate-spin inline-block" aria-hidden="true" />
<span className="sr-only">{t('landing.form.loadingAriaLabel')}</span>
</span>
) : ( ) : (
<> <>
NOTIFIER MOI {/* BUG-05 fix: Correct French imperative form via i18n */}
{t('landing.notify.submit')}
<ArrowRight size={14} /> <ArrowRight size={14} />
</> </>
)} )}
@ -594,13 +633,13 @@ export default function LandingPage() {
</motion.form> </motion.form>
{status === 'error' && ( {status === 'error' && (
<p className="text-xs text-[var(--sumi-vermillion)] mt-2">{errorMsg}</p> <p className="text-xs text-[var(--sumi-vermillion)] mt-2" data-testid="launch-error-message">{errorMsg}</p>
)} )}
</div> </div>
</Section> </Section>
{/* ═══ FOOTER ═══ */} {/* ═══ FOOTER ═══ */}
<footer className="relative z-10 border-t border-[var(--sumi-border-faint)] py-16 px-6"> <footer className="relative z-10 border-t border-[var(--sumi-border-faint)] py-16 px-6" data-testid="launch-footer">
<div className="max-w-[1000px] mx-auto"> <div className="max-w-[1000px] mx-auto">
<div className="flex flex-col md:flex-row items-center justify-between gap-8"> <div className="flex flex-col md:flex-row items-center justify-between gap-8">
{/* Logo */} {/* Logo */}
@ -616,16 +655,13 @@ export default function LandingPage() {
</span> </span>
</div> </div>
{/* Links */} {/* Links — BUG-08 fix: external links get target="_blank" rel="noopener noreferrer" */}
<nav className="flex items-center gap-8" aria-label="Footer navigation"> <nav className="flex items-center gap-8" aria-label="Footer navigation">
{[ {footerLinks.map((link) => (
{ label: 'Open Source', href: 'https://github.com/talas-audio' },
{ label: 'Confidentialité', href: '/privacy' },
{ label: 'Contact', href: 'mailto:contact@talas.audio' },
].map((link) => (
<a <a
key={link.label} key={link.label}
href={link.href} href={link.href}
{...(link.external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
className="text-xs text-[var(--sumi-text-tertiary)] hover:text-[var(--sumi-text-secondary)] transition-colors tracking-[0.05em]" className="text-xs text-[var(--sumi-text-tertiary)] hover:text-[var(--sumi-text-secondary)] transition-colors tracking-[0.05em]"
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }} style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
> >
@ -641,11 +677,11 @@ export default function LandingPage() {
className="text-[10px] text-[var(--sumi-text-disabled)] tracking-[0.15em]" className="text-[10px] text-[var(--sumi-text-disabled)] tracking-[0.15em]"
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }} style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
> >
TECHNOLOGIE AUDIO ÉTHIQUE FAIT EN FRANCE {new Date().getFullYear()} {t('landing.footer.tagline')} {new Date().getFullYear()}
</p> </p>
</div> </div>
</div> </div>
</footer> </footer>
</div> </main>
); );
} }

View file

@ -2,9 +2,17 @@
* Queue page unified in features/library (Audit 2.1) * Queue page unified in features/library (Audit 2.1)
*/ */
import { useEffect } from 'react';
import { useTranslation } from '@/hooks/useTranslation';
import { QueueView } from '@/components/library/playlists/QueueView'; import { QueueView } from '@/components/library/playlists/QueueView';
export function QueuePage() { export function QueuePage() {
const { t } = useTranslation();
useEffect(() => {
document.title = t('queue.pageTitle');
}, [t]);
return <QueueView />; return <QueueView />;
} }

View file

@ -1,19 +1,22 @@
import { EmptyState } from '@/components/ui/empty-state'; import { EmptyState } from '@/components/ui/empty-state';
import { FileAudio } from 'lucide-react'; import { FileAudio } from 'lucide-react';
import { useTranslation } from '@/hooks/useTranslation';
interface LibraryPageEmptyProps { interface LibraryPageEmptyProps {
onUploadClick: () => void; onUploadClick: () => void;
} }
export function LibraryPageEmpty({ onUploadClick }: LibraryPageEmptyProps) { export function LibraryPageEmpty({ onUploadClick }: LibraryPageEmptyProps) {
const { t } = useTranslation();
return ( return (
<EmptyState <EmptyState
variant="centered" variant="centered"
icon={<FileAudio className="w-full h-full" />} icon={<FileAudio className="w-full h-full" />}
title="Your library is empty" title={t('library.empty.title')}
description="Upload your first track or create a playlist to get started." description={t('library.empty.description')}
action={{ action={{
label: 'Upload Track', label: t('library.empty.uploadTrack'),
onClick: onUploadClick, onClick: onUploadClick,
}} }}
size="lg" size="lg"

View file

@ -2,6 +2,7 @@ import { motion } from 'framer-motion';
import { Card } from '@/components/ui/card'; import { Card } from '@/components/ui/card';
import { Play, Clock, FileAudio, CheckCircle2, Circle } from 'lucide-react'; import { Play, Clock, FileAudio, CheckCircle2, Circle } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from '@/hooks/useTranslation';
import type { Track } from '@/types'; import type { Track } from '@/types';
import { getArtistName, formatDuration, safeFormatDistanceToNow } from './utils'; import { getArtistName, formatDuration, safeFormatDistanceToNow } from './utils';
@ -21,8 +22,10 @@ export function LibraryPageGrid({
onToggleSelection, onToggleSelection,
onPlayTrack, onPlayTrack,
}: LibraryPageGridProps) { }: LibraryPageGridProps) {
const { t } = useTranslation();
return ( return (
<section aria-label="Library tracks grid"> <section aria-label={t('library.grid.label')}>
<motion.div <motion.div
className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-6" className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-6"
variants={listVariants} variants={listVariants}
@ -65,6 +68,7 @@ export function LibraryPageGrid({
)} )}
<button <button
type="button" type="button"
aria-label={t('library.grid.play', { title: track.title })}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onPlayTrack(track); onPlayTrack(track);

View file

@ -5,6 +5,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { Play, Download, Trash2, MoreVertical } from 'lucide-react'; import { Play, Download, Trash2, MoreVertical } from 'lucide-react';
import { useTranslation } from '@/hooks/useTranslation';
import type { Track } from '@/types'; import type { Track } from '@/types';
import { getArtistName, formatDuration, safeFormatDistanceToNow } from './utils'; import { getArtistName, formatDuration, safeFormatDistanceToNow } from './utils';
@ -14,16 +15,18 @@ interface LibraryPageListProps {
} }
export function LibraryPageList({ tracks, onPlayTrack }: LibraryPageListProps) { export function LibraryPageList({ tracks, onPlayTrack }: LibraryPageListProps) {
const { t } = useTranslation();
return ( return (
<div className="glass rounded-2xl overflow-hidden shadow-lg"> <div className="glass rounded-2xl overflow-hidden shadow-lg">
<table className="w-full text-left text-sm"> <table className="w-full text-left text-sm" aria-label={t('library.table.label')}>
<thead className="bg-black/20 text-xs uppercase font-mono text-muted-foreground"> <thead className="bg-black/20 text-xs uppercase font-mono text-muted-foreground">
<tr> <tr>
<th className="px-6 py-4 w-12 text-center">#</th> <th className="px-6 py-4 w-12 text-center">#</th>
<th className="px-6 py-4">Title</th> <th className="px-6 py-4">{t('library.table.title')}</th>
<th className="px-6 py-4 hidden md:table-cell">Artist</th> <th className="px-6 py-4 hidden md:table-cell">{t('library.table.artist')}</th>
<th className="px-6 py-4 hidden sm:table-cell">Date</th> <th className="px-6 py-4 hidden sm:table-cell">{t('library.table.date')}</th>
<th className="px-6 py-4 text-right">Duration</th> <th className="px-6 py-4 text-right">{t('library.table.duration')}</th>
<th className="px-6 py-4 w-12" /> <th className="px-6 py-4 w-12" />
</tr> </tr>
</thead> </thead>
@ -57,6 +60,7 @@ export function LibraryPageList({ tracks, onPlayTrack }: LibraryPageListProps) {
type="button" type="button"
className="p-2 hover:bg-white/10 rounded-full transition-colors duration-[var(--duration-fast)] opacity-0 group-hover:opacity-100 text-muted-foreground" className="p-2 hover:bg-white/10 rounded-full transition-colors duration-[var(--duration-fast)] opacity-0 group-hover:opacity-100 text-muted-foreground"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
aria-label={t('library.table.moreOptions', { title: track.title })}
> >
<MoreVertical className="w-4 h-4" /> <MoreVertical className="w-4 h-4" />
</button> </button>
@ -66,10 +70,10 @@ export function LibraryPageList({ tracks, onPlayTrack }: LibraryPageListProps) {
className="bg-background/90 backdrop-blur-xl border-border" className="bg-background/90 backdrop-blur-xl border-border"
> >
<DropdownMenuItem className="cursor-pointer gap-2 focus:bg-primary/20"> <DropdownMenuItem className="cursor-pointer gap-2 focus:bg-primary/20">
<Download className="w-4 h-4" /> Download <Download className="w-4 h-4" /> {t('library.table.download')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer gap-2 text-destructive focus:bg-destructive/10 focus:text-destructive"> <DropdownMenuItem className="cursor-pointer gap-2 text-destructive focus:bg-destructive/10 focus:text-destructive">
<Trash2 className="w-4 h-4" /> Delete <Trash2 className="w-4 h-4" /> {t('library.table.delete')}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View file

@ -2,6 +2,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Search, Grid, List as ListIcon, Plus } from 'lucide-react'; import { Search, Grid, List as ListIcon, Plus } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from '@/hooks/useTranslation';
import type { LibraryPageViewMode } from './types'; import type { LibraryPageViewMode } from './types';
interface LibraryPageToolbarProps { interface LibraryPageToolbarProps {
@ -19,14 +20,16 @@ export function LibraryPageToolbar({
onSearchChange, onSearchChange,
onNewClick, onNewClick,
}: LibraryPageToolbarProps) { }: LibraryPageToolbarProps) {
const { t } = useTranslation();
return ( return (
<div className="sticky top-0 z-20 bg-background/80 backdrop-blur-xl border-b border-border py-4 -mx-4 px-4 md:-mx-8 md:px-8 flex flex-col md:flex-row gap-4 justify-between items-center transition-all"> <div className="sticky top-0 z-20 bg-background/80 backdrop-blur-xl border-b border-border py-4 -mx-4 px-4 md:-mx-8 md:px-8 flex flex-col md:flex-row gap-4 justify-between items-center transition-all">
<div className="flex items-center gap-4 w-full md:w-auto"> <div className="flex items-center gap-4 w-full md:w-auto">
<h1 className="text-heading-2 font-heading text-foreground hidden md:block">Library</h1> <h1 className="text-heading-2 font-heading text-foreground hidden md:block">{t('library.title')}</h1>
<div className="relative flex-1 md:w-80"> <div className="relative flex-1 md:w-80">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input <Input
placeholder="Search..." placeholder={t('library.searchPlaceholder')}
value={searchQuery} value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)} onChange={(e) => onSearchChange(e.target.value)}
className="pl-9 bg-muted/20 border-transparent focus:bg-background focus:ring-1 focus:ring-primary/50 transition-all rounded-xl" className="pl-9 bg-muted/20 border-transparent focus:bg-background focus:ring-1 focus:ring-primary/50 transition-all rounded-xl"
@ -42,7 +45,7 @@ export function LibraryPageToolbar({
'h-8 w-8 flex items-center justify-center rounded-md transition-all', 'h-8 w-8 flex items-center justify-center rounded-md transition-all',
viewMode === 'grid' ? 'bg-background text-primary shadow-sm' : 'text-muted-foreground hover:text-foreground' viewMode === 'grid' ? 'bg-background text-primary shadow-sm' : 'text-muted-foreground hover:text-foreground'
)} )}
aria-label="Grid view" aria-label={t('library.viewMode.grid')}
> >
<Grid className="w-4 h-4" /> <Grid className="w-4 h-4" />
</button> </button>
@ -53,7 +56,7 @@ export function LibraryPageToolbar({
'h-8 w-8 flex items-center justify-center rounded-md transition-all', 'h-8 w-8 flex items-center justify-center rounded-md transition-all',
viewMode === 'list' ? 'bg-background text-primary shadow-sm' : 'text-muted-foreground hover:text-foreground' viewMode === 'list' ? 'bg-background text-primary shadow-sm' : 'text-muted-foreground hover:text-foreground'
)} )}
aria-label="List view" aria-label={t('library.viewMode.list')}
> >
<ListIcon className="w-4 h-4" /> <ListIcon className="w-4 h-4" />
</button> </button>
@ -62,7 +65,7 @@ export function LibraryPageToolbar({
onClick={onNewClick} onClick={onNewClick}
className="shadow-sm transition-all bg-primary text-primary-foreground" className="shadow-sm transition-all bg-primary text-primary-foreground"
> >
<Plus className="w-4 h-4 mr-2" /> New <Plus className="w-4 h-4 mr-2" /> {t('library.new')}
</Button> </Button>
</div> </div>
</div> </div>

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test'; import { fn } from 'storybook/test';
import { PlaybackSpeedControl } from './PlaybackSpeedControl'; import { PlaybackSpeedControl } from './PlaybackSpeedControl';
/** /**

View file

@ -41,6 +41,8 @@ export function PlayerControls({
<Tooltip content="Shuffle"> <Tooltip content="Shuffle">
<button <button
onClick={onShuffle} onClick={onShuffle}
aria-label={shuffle ? "Disable shuffle" : "Enable shuffle"}
aria-pressed={shuffle}
className={cn( className={cn(
iconBtnClass, iconBtnClass,
size, size,
@ -55,6 +57,7 @@ export function PlayerControls({
<button <button
data-testid="prev-button" data-testid="prev-button"
aria-label="Previous track"
onClick={onPrevious} onClick={onPrevious}
className={cn(iconBtnClass, size, "text-foreground hover:text-primary hover:bg-white/5")} className={cn(iconBtnClass, size, "text-foreground hover:text-primary hover:bg-white/5")}
> >
@ -63,6 +66,7 @@ export function PlayerControls({
<button <button
data-testid="play-button" data-testid="play-button"
aria-label={isPlaying ? "Pause" : "Play"}
onClick={onPlayPause} onClick={onPlayPause}
className={cn( className={cn(
"flex items-center justify-center rounded-full bg-primary text-primary-foreground flex-shrink-0 active:scale-95 transition-all shadow-sm", "flex items-center justify-center rounded-full bg-primary text-primary-foreground flex-shrink-0 active:scale-95 transition-all shadow-sm",
@ -78,6 +82,7 @@ export function PlayerControls({
<button <button
data-testid="next-button" data-testid="next-button"
aria-label="Next track"
onClick={onNext} onClick={onNext}
className={cn(iconBtnClass, size, "text-foreground hover:text-primary hover:bg-white/5")} className={cn(iconBtnClass, size, "text-foreground hover:text-primary hover:bg-white/5")}
> >
@ -87,6 +92,8 @@ export function PlayerControls({
<Tooltip content="Repeat"> <Tooltip content="Repeat">
<button <button
onClick={onRepeat} onClick={onRepeat}
aria-label={repeat === 'off' ? 'Enable repeat' : repeat === 'track' ? 'Repeat track' : 'Repeat playlist'}
aria-pressed={repeat !== 'off'}
className={cn( className={cn(
iconBtnClass, iconBtnClass,
size, size,

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test'; import { fn } from 'storybook/test';
import { PlayerError } from './PlayerError'; import { PlayerError } from './PlayerError';
/** /**

View file

@ -1,6 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { QualitySelector } from './QualitySelector'; import { QualitySelector } from './QualitySelector';
import { useArgs } from '@storybook/preview-api'; import { useArgs } from 'storybook/preview-api';
const meta: Meta = { const meta: Meta = {
title: 'Components/Features/Player/QualitySelector', title: 'Components/Features/Player/QualitySelector',

View file

@ -1,6 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { RepeatShuffleButtons } from './RepeatShuffleButtons'; import { RepeatShuffleButtons } from './RepeatShuffleButtons';
import { useArgs } from '@storybook/preview-api'; import { useArgs } from 'storybook/preview-api';
const meta: Meta = { const meta: Meta = {
title: 'Components/Features/Player/RepeatShuffleButtons', title: 'Components/Features/Player/RepeatShuffleButtons',

View file

@ -198,6 +198,9 @@ export function usePlayer(
// Callback pour les erreurs (Invalid URI, réseau, etc.) // Callback pour les erreurs (Invalid URI, réseau, etc.)
audioPlayerService.onError((error) => { audioPlayerService.onError((error) => {
// Ignore errors when no track is loaded (e.g. on initial page load)
if (!store.currentTrack?.url) return;
const msg = error instanceof Error ? error.message : String(error); const msg = error instanceof Error ? error.message : String(error);
// Try fallback to direct audio download URL before giving up // Try fallback to direct audio download URL before giving up

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test'; import { fn } from 'storybook/test';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import { import {
AddCollaboratorModal, AddCollaboratorModal,

View file

@ -1,7 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { AddTrackToPlaylistModal, AddTrackToPlaylistModalSkeleton } from './AddTrackToPlaylistModal'; import { AddTrackToPlaylistModal, AddTrackToPlaylistModalSkeleton } from './AddTrackToPlaylistModal';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useArgs } from '@storybook/preview-api'; import { useArgs } from 'storybook/preview-api';
const meta: Meta<typeof AddTrackToPlaylistModal> = { const meta: Meta<typeof AddTrackToPlaylistModal> = {
title: 'Components/Features/Playlists/AddTrackToPlaylistModal', title: 'Components/Features/Playlists/AddTrackToPlaylistModal',

View file

@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { Copy } from 'lucide-react'; import { Copy } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/hooks/useTranslation';
import { duplicatePlaylist } from '../services/playlistService'; import { duplicatePlaylist } from '../services/playlistService';
import type { Playlist } from '../types'; import type { Playlist } from '../types';
@ -26,6 +27,7 @@ export const DuplicatePlaylistButton: React.FC<DuplicatePlaylistButtonProps> = (
size = 'sm', size = 'sm',
className, className,
}) => { }) => {
const { t } = useTranslation();
const [isDuplicating, setIsDuplicating] = useState(false); const [isDuplicating, setIsDuplicating] = useState(false);
const { success: showSuccess, error: showError } = useToast(); const { success: showSuccess, error: showError } = useToast();
@ -33,15 +35,15 @@ export const DuplicatePlaylistButton: React.FC<DuplicatePlaylistButtonProps> = (
setIsDuplicating(true); setIsDuplicating(true);
try { try {
const newPlaylist = await duplicatePlaylist(playlistId, { const newPlaylist = await duplicatePlaylist(playlistId, {
new_title: `${playlistTitle} (copie)`, new_title: t('playlists.duplicate.copySuffix', { title: playlistTitle }),
}); });
showSuccess('Playlist dupliquée avec succès'); showSuccess(t('playlists.duplicate.success'));
onSuccess?.(newPlaylist); onSuccess?.(newPlaylist);
} catch (err) { } catch (err) {
showError( showError(
err instanceof Error err instanceof Error
? err.message ? err.message
: 'Erreur lors de la duplication de la playlist', : t('playlists.duplicate.error'),
); );
} finally { } finally {
setIsDuplicating(false); setIsDuplicating(false);
@ -55,10 +57,10 @@ export const DuplicatePlaylistButton: React.FC<DuplicatePlaylistButtonProps> = (
onClick={handleDuplicate} onClick={handleDuplicate}
disabled={isDuplicating} disabled={isDuplicating}
className={`gap-2 ${className ?? ''}`} className={`gap-2 ${className ?? ''}`}
aria-label="Dupliquer la playlist" aria-label={t('playlists.duplicate.ariaLabel')}
> >
<Copy className="h-4 w-4" aria-hidden /> <Copy className="h-4 w-4" aria-hidden />
{isDuplicating ? 'Duplication...' : 'Dupliquer'} {isDuplicating ? t('playlists.duplicate.duplicating') : t('playlists.duplicate.button')}
</Button> </Button>
); );
}; };

View file

@ -9,6 +9,7 @@ import {
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/hooks/useTranslation';
import { TokenStorage } from '@/services/tokenStorage'; import { TokenStorage } from '@/services/tokenStorage';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
@ -26,6 +27,7 @@ export const ExportPlaylistButton: React.FC<ExportPlaylistButtonProps> = ({
playlistId, playlistId,
playlistTitle = 'playlist', playlistTitle = 'playlist',
}) => { }) => {
const { t } = useTranslation();
const [isExporting, setIsExporting] = useState(false); const [isExporting, setIsExporting] = useState(false);
const { success: showSuccess, error: showError } = useToast(); const { success: showSuccess, error: showError } = useToast();
@ -37,7 +39,7 @@ export const ExportPlaylistButton: React.FC<ExportPlaylistButtonProps> = ({
// Récupérer le token d'authentification // Récupérer le token d'authentification
const token = TokenStorage.getAccessToken(); const token = TokenStorage.getAccessToken();
if (!token) { if (!token) {
showError('Vous devez être connecté pour exporter une playlist'); showError(t('playlists.export.authRequired'));
return; return;
} }
@ -51,14 +53,12 @@ export const ExportPlaylistButton: React.FC<ExportPlaylistButtonProps> = ({
if (!response.ok) { if (!response.ok) {
if (response.status === 403) { if (response.status === 403) {
throw new Error( throw new Error(t('playlists.export.forbidden'));
"Vous n'avez pas la permission d'exporter cette playlist",
);
} }
if (response.status === 404) { if (response.status === 404) {
throw new Error('Playlist non trouvée'); throw new Error(t('playlists.export.notFound'));
} }
throw new Error("Erreur lors de l'export"); throw new Error(t('playlists.export.error'));
} }
// Récupérer le blob // Récupérer le blob
@ -85,15 +85,13 @@ export const ExportPlaylistButton: React.FC<ExportPlaylistButtonProps> = ({
document.body.removeChild(link); document.body.removeChild(link);
window.URL.revokeObjectURL(downloadUrl); window.URL.revokeObjectURL(downloadUrl);
showSuccess( showSuccess(t('playlists.export.success', { format: format.toUpperCase() }));
`La playlist a été exportée en format ${format.toUpperCase()}`,
);
} catch (error) { } catch (error) {
logger.error('Export error:', { error }); logger.error('Export error:', { error });
showError( showError(
error instanceof Error error instanceof Error
? error.message ? error.message
: "Une erreur est survenue lors de l'export", : t('playlists.export.genericError'),
); );
} finally { } finally {
setIsExporting(false); setIsExporting(false);
@ -110,7 +108,7 @@ export const ExportPlaylistButton: React.FC<ExportPlaylistButtonProps> = ({
className="gap-2" className="gap-2"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
{isExporting ? 'Export...' : 'Exporter'} {isExporting ? t('playlists.export.exporting') : t('playlists.export.button')}
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
@ -118,19 +116,19 @@ export const ExportPlaylistButton: React.FC<ExportPlaylistButtonProps> = ({
onClick={() => handleExport('json')} onClick={() => handleExport('json')}
disabled={isExporting} disabled={isExporting}
> >
Exporter en JSON {t('playlists.export.json')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => handleExport('csv')} onClick={() => handleExport('csv')}
disabled={isExporting} disabled={isExporting}
> >
Exporter en CSV {t('playlists.export.csv')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => handleExport('m3u')} onClick={() => handleExport('m3u')}
disabled={isExporting} disabled={isExporting}
> >
Exporter en M3U {t('playlists.export.m3u')}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View file

@ -5,6 +5,7 @@ import { Dialog } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/hooks/useTranslation';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { playlistsApi } from '@/services/api/playlists'; import { playlistsApi } from '@/services/api/playlists';
@ -23,6 +24,7 @@ export const ImportPlaylistButton: React.FC<ImportPlaylistButtonProps> = ({
onImported, onImported,
className, className,
}) => { }) => {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isImporting, setIsImporting] = useState(false); const [isImporting, setIsImporting] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null); const [selectedFile, setSelectedFile] = useState<File | null>(null);
@ -175,13 +177,13 @@ export const ImportPlaylistButton: React.FC<ImportPlaylistButtonProps> = ({
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
> >
<Upload className="h-4 w-4 mr-2" /> <Upload className="h-4 w-4 mr-2" />
Importer {t('playlists.importButton')}
</Button> </Button>
<Dialog <Dialog
open={isOpen} open={isOpen}
onClose={handleClose} onClose={handleClose}
title="Importer une playlist" title={t('playlists.importButton')}
footer={footer} footer={footer}
> >
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">

View file

@ -12,6 +12,7 @@ import {
} from '../services/playlistService'; } from '../services/playlistService';
import type { Playlist } from '../types'; import type { Playlist } from '../types';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/hooks/useTranslation';
import { useUser } from '@/features/auth/hooks/useUser'; import { useUser } from '@/features/auth/hooks/useUser';
/** /**
@ -39,6 +40,7 @@ export function PlaylistFollowButton({
variant, variant,
showCount = false, showCount = false,
}: PlaylistFollowButtonProps) { }: PlaylistFollowButtonProps) {
const { t } = useTranslation();
const { data: user } = useUser(); const { data: user } = useUser();
const { success: showSuccess, error: showError } = useToast(); const { success: showSuccess, error: showError } = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -90,7 +92,7 @@ export function PlaylistFollowButton({
setIsUpdating(true); setIsUpdating(true);
}, },
onSuccess: () => { onSuccess: () => {
showSuccess('Vous suivez maintenant cette playlist'); showSuccess(t('playlists.followBtn.followSuccess'));
onFollowChange?.(true); onFollowChange?.(true);
// Invalidate queries to refresh data // Invalidate queries to refresh data
queryClient.invalidateQueries({ queryKey: ['playlist', playlistId] }); queryClient.invalidateQueries({ queryKey: ['playlist', playlistId] });
@ -108,7 +110,7 @@ export function PlaylistFollowButton({
err.response?.data?.error?.message || err.response?.data?.error?.message ||
err.response?.data?.message || err.response?.data?.message ||
err.message || err.message ||
"Erreur lors de l'abonnement à la playlist"; t('playlists.followBtn.followError');
showError(errorMessage); showError(errorMessage);
}, },
onSettled: () => { onSettled: () => {
@ -126,7 +128,7 @@ export function PlaylistFollowButton({
setIsUpdating(true); setIsUpdating(true);
}, },
onSuccess: () => { onSuccess: () => {
showSuccess('Vous ne suivez plus cette playlist'); showSuccess(t('playlists.followBtn.unfollowSuccess'));
onFollowChange?.(false); onFollowChange?.(false);
// Invalidate queries to refresh data // Invalidate queries to refresh data
queryClient.invalidateQueries({ queryKey: ['playlist', playlistId] }); queryClient.invalidateQueries({ queryKey: ['playlist', playlistId] });
@ -144,7 +146,7 @@ export function PlaylistFollowButton({
err.response?.data?.error?.message || err.response?.data?.error?.message ||
err.response?.data?.message || err.response?.data?.message ||
err.message || err.message ||
'Erreur lors du désabonnement de la playlist'; t('playlists.followBtn.unfollowError');
showError(errorMessage); showError(errorMessage);
}, },
onSettled: () => { onSettled: () => {
@ -183,12 +185,12 @@ export function PlaylistFollowButton({
{isLoading ? ( {isLoading ? (
<> <>
<LoadingSpinner size="sm" inline className="mr-2" /> <LoadingSpinner size="sm" inline className="mr-2" />
{following ? 'Désabonnement...' : 'Abonnement...'} {following ? t('playlists.followBtn.unfollowing') : t('playlists.followBtn.subscribing')}
</> </>
) : following ? ( ) : following ? (
<> <>
<UserCheck className="h-4 w-4 mr-2" /> <UserCheck className="h-4 w-4 mr-2" />
Abonné {t('playlists.followBtn.following')}
{showCount && followerCount > 0 && ( {showCount && followerCount > 0 && (
<span className="ml-2 text-xs">({followerCount})</span> <span className="ml-2 text-xs">({followerCount})</span>
)} )}
@ -196,7 +198,7 @@ export function PlaylistFollowButton({
) : ( ) : (
<> <>
<UserPlus className="h-4 w-4 mr-2" /> <UserPlus className="h-4 w-4 mr-2" />
Suivre {t('playlists.followBtn.follow')}
{showCount && followerCount > 0 && ( {showCount && followerCount > 0 && (
<span className="ml-2 text-xs">({followerCount})</span> <span className="ml-2 text-xs">({followerCount})</span>
)} )}

View file

@ -3,6 +3,7 @@
* T0461: Create Playlist Create/Edit Form * T0461: Create Playlist Create/Edit Form
*/ */
import { useMemo } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
@ -14,6 +15,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { LoadingSpinner } from '@/components/ui/loading-spinner'; import { LoadingSpinner } from '@/components/ui/loading-spinner';
import { useCreatePlaylist, useUpdatePlaylist } from '../hooks/usePlaylist'; import { useCreatePlaylist, useUpdatePlaylist } from '../hooks/usePlaylist';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/hooks/useTranslation';
import { useIsRateLimited } from '@/hooks/useIsRateLimited'; import { useIsRateLimited } from '@/hooks/useIsRateLimited';
import type { import type {
Playlist, Playlist,
@ -21,42 +23,12 @@ import type {
UpdatePlaylistRequest, UpdatePlaylistRequest,
} from '../types'; } from '../types';
// Schéma de validation type PlaylistFormData = {
const playlistFormSchema = z.object({ title: string;
title: z description?: string;
.string() is_public: boolean;
.min(1, 'Le titre est requis') cover_url?: string;
.max(200, 'Le titre ne peut pas dépasser 200 caractères'), };
description: z
.string()
.max(2000, 'La description ne peut pas dépasser 2000 caractères')
.optional()
.or(z.literal('')),
is_public: z.boolean().default(true),
cover_url: z
.string()
.optional()
.refine((val) => !val || val === '' || val.length <= 500, {
message: "L'URL ne peut pas dépasser 500 caractères",
})
.refine(
(val) => {
if (!val || val === '') return true;
try {
new URL(val);
return true;
} catch {
return false;
}
},
{
message: "L'URL de la couverture doit être valide",
},
)
.transform((val) => (val === '' ? undefined : val)),
});
type PlaylistFormData = z.infer<typeof playlistFormSchema>;
interface PlaylistFormProps { interface PlaylistFormProps {
playlist?: Playlist; playlist?: Playlist;
@ -75,6 +47,7 @@ export function PlaylistForm({
submitLabel, submitLabel,
className, className,
}: PlaylistFormProps) { }: PlaylistFormProps) {
const { t } = useTranslation();
const { success: showSuccess, error: showError } = useToast(); const { success: showSuccess, error: showError } = useToast();
const createMutation = useCreatePlaylist(); const createMutation = useCreatePlaylist();
const updateMutation = useUpdatePlaylist(); const updateMutation = useUpdatePlaylist();
@ -83,6 +56,44 @@ export function PlaylistForm({
const isEditMode = !!playlist; const isEditMode = !!playlist;
const isSubmitting = createMutation.isPending || updateMutation.isPending; const isSubmitting = createMutation.isPending || updateMutation.isPending;
const playlistFormSchema = useMemo(
() =>
z.object({
title: z
.string()
.min(1, t('playlists.form.titleRequired'))
.max(200, t('playlists.form.titleMaxLength')),
description: z
.string()
.max(2000, t('playlists.form.descriptionMaxLength'))
.optional()
.or(z.literal('')),
is_public: z.boolean().default(true),
cover_url: z
.string()
.optional()
.refine((val) => !val || val === '' || val.length <= 500, {
message: t('playlists.form.coverUrlMaxLength'),
})
.refine(
(val) => {
if (!val || val === '') return true;
try {
new URL(val);
return true;
} catch {
return false;
}
},
{
message: t('playlists.form.coverUrlInvalid'),
},
)
.transform((val) => (val === '' ? undefined : val)),
}),
[t],
);
const { const {
register, register,
handleSubmit, handleSubmit,
@ -116,14 +127,14 @@ export function PlaylistForm({
id: playlist.id, id: playlist.id,
data: formData as UpdatePlaylistRequest, data: formData as UpdatePlaylistRequest,
}); });
showSuccess('Playlist mise à jour avec succès'); showSuccess(t('playlists.form.updateSuccess'));
} else { } else {
await createMutation.mutateAsync(formData as CreatePlaylistRequest); await createMutation.mutateAsync(formData as CreatePlaylistRequest);
showSuccess('Playlist créée avec succès'); showSuccess(t('playlists.form.createSuccess'));
} }
} catch (error) { } catch (error) {
showError( showError(
error instanceof Error ? error.message : 'Une erreur est survenue', error instanceof Error ? error.message : t('playlists.form.genericError'),
); );
} }
}; };
@ -134,15 +145,15 @@ export function PlaylistForm({
className={className} className={className}
aria-label={ aria-label={
isEditMode isEditMode
? 'Formulaire de modification de playlist' ? t('playlists.form.editAriaLabel')
: 'Formulaire de création de playlist' : t('playlists.form.createAriaLabel')
} }
> >
<div className="space-y-4"> <div className="space-y-4">
{/* Title */} {/* Title */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="title"> <Label htmlFor="title">
Titre{' '} {t('playlists.form.titleLabel')}{' '}
<span className="text-destructive" aria-hidden="true"> <span className="text-destructive" aria-hidden="true">
* *
</span> </span>
@ -150,7 +161,7 @@ export function PlaylistForm({
<Input <Input
id="title" id="title"
{...register('title')} {...register('title')}
placeholder="Ma playlist" placeholder={t('playlists.form.titlePlaceholder')}
disabled={isSubmitting} disabled={isSubmitting}
aria-invalid={errors.title ? 'true' : 'false'} aria-invalid={errors.title ? 'true' : 'false'}
aria-describedby={errors.title ? 'title-error' : undefined} aria-describedby={errors.title ? 'title-error' : undefined}
@ -169,11 +180,11 @@ export function PlaylistForm({
{/* Description */} {/* Description */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="description">Description</Label> <Label htmlFor="description">{t('playlists.form.descriptionLabel')}</Label>
<Textarea <Textarea
id="description" id="description"
{...register('description')} {...register('description')}
placeholder="Décrivez votre playlist..." placeholder={t('playlists.form.descriptionPlaceholder')}
rows={4} rows={4}
disabled={isSubmitting} disabled={isSubmitting}
aria-invalid={errors.description ? 'true' : 'false'} aria-invalid={errors.description ? 'true' : 'false'}
@ -194,7 +205,7 @@ export function PlaylistForm({
{/* Cover URL */} {/* Cover URL */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="cover_url">URL de la couverture</Label> <Label htmlFor="cover_url">{t('playlists.form.coverUrlLabel')}</Label>
<Input <Input
id="cover_url" id="cover_url"
type="url" type="url"
@ -225,7 +236,7 @@ export function PlaylistForm({
aria-checked={isPublic} aria-checked={isPublic}
/> />
<Label htmlFor="is_public" className="cursor-pointer"> <Label htmlFor="is_public" className="cursor-pointer">
Playlist publique {t('playlists.form.isPublic')}
</Label> </Label>
</div> </div>
@ -237,9 +248,9 @@ export function PlaylistForm({
variant="outline" variant="outline"
onClick={onCancel} onClick={onCancel}
disabled={isSubmitting} disabled={isSubmitting}
aria-label="Annuler la modification" aria-label={t('playlists.form.cancelEdit')}
> >
Annuler {t('playlists.form.cancel')}
</Button> </Button>
)} )}
<Button <Button
@ -248,12 +259,12 @@ export function PlaylistForm({
aria-label={ aria-label={
submitLabel || submitLabel ||
(isEditMode (isEditMode
? 'Enregistrer les modifications' ? t('playlists.form.saveChanges')
: 'Créer la playlist') : t('playlists.form.createPlaylist'))
} }
> >
{isSubmitting && <LoadingSpinner size="sm" inline className="mr-2" />} {isSubmitting && <LoadingSpinner size="sm" inline className="mr-2" />}
{submitLabel || (isEditMode ? 'Enregistrer' : 'Créer')} {submitLabel || (isEditMode ? t('playlists.form.save') : t('playlists.form.create'))}
</Button> </Button>
</div> </div>
</div> </div>

View file

@ -6,6 +6,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Play, Pause, Music, GripVertical } from 'lucide-react'; import { Play, Pause, Music, GripVertical } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from '@/hooks/useTranslation';
import { RemoveTrackButton } from './RemoveTrackButton'; import { RemoveTrackButton } from './RemoveTrackButton';
import { AddToFavorisButton } from './AddToFavorisButton'; import { AddToFavorisButton } from './AddToFavorisButton';
import type { PlaylistTrack, Track } from '../types'; import type { PlaylistTrack, Track } from '../types';
@ -52,6 +53,7 @@ export function PlaylistTrackItem({
canRemoveTracks = true, canRemoveTracks = true,
isFavorisPlaylist = false, isFavorisPlaylist = false,
}: PlaylistTrackItemProps) { }: PlaylistTrackItemProps) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const handleClick = () => { const handleClick = () => {
@ -77,14 +79,14 @@ export function PlaylistTrackItem({
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
role="listitem" role="listitem"
aria-label={`Piste ${position}: ${track.title}`} aria-label={t('playlists.trackItem', { position: position + 1, title: track.title })}
> >
{/* Handle de drag (si drag-and-drop est activé) */} {/* Handle de drag (si drag-and-drop est activé) */}
{dragHandleProps && ( {dragHandleProps && (
<div <div
{...dragHandleProps} {...dragHandleProps}
className="flex-shrink-0 cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground transition-colors duration-[var(--duration-fast)]" className="flex-shrink-0 cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground transition-colors duration-[var(--duration-fast)]"
aria-label="Réorganiser" aria-label={t('playlists.detail.reorder')}
> >
<GripVertical className="h-5 w-5" /> <GripVertical className="h-5 w-5" />
</div> </div>
@ -99,8 +101,8 @@ export function PlaylistTrackItem({
className="p-1 rounded-full hover:bg-muted dark:hover:bg-muted active:bg-muted dark:active:bg-muted transition-colors duration-[var(--duration-fast)] touch-manipulation min-h-8 min-w-8 sm:min-h-0 sm:min-w-0" className="p-1 rounded-full hover:bg-muted dark:hover:bg-muted active:bg-muted dark:active:bg-muted transition-colors duration-[var(--duration-fast)] touch-manipulation min-h-8 min-w-8 sm:min-h-0 sm:min-w-0"
aria-label={ aria-label={
isPlaying isPlaying
? `Mettre en pause ${track.title}` ? t('playlists.detail.pauseTrack', { title: track.title })
: `Lire ${track.title}` : t('playlists.detail.playTrack', { title: track.title })
} }
> >
{isPlaying ? ( {isPlaying ? (
@ -110,7 +112,7 @@ export function PlaylistTrackItem({
)} )}
</button> </button>
) : ( ) : (
<span>{position}</span> <span>{position + 1}</span>
)} )}
</div> </div>
@ -119,7 +121,7 @@ export function PlaylistTrackItem({
{track.cover_art_path ? ( {track.cover_art_path ? (
<img <img
src={track.cover_art_path} src={track.cover_art_path}
alt={`Cover de ${track.title}`} alt={t('playlists.detail.coverAlt', { title: track.title })}
className="w-10 h-10 sm:w-12 sm:h-12 rounded-md object-cover" className="w-10 h-10 sm:w-12 sm:h-12 rounded-md object-cover"
/> />
) : ( ) : (
@ -150,7 +152,7 @@ export function PlaylistTrackItem({
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
{!isFavorisPlaylist && isHovered && ( {!isFavorisPlaylist && isHovered && (
<AddToFavorisButton trackId={track.id} aria-label={`Add ${track.title} to Favorites`} /> <AddToFavorisButton trackId={track.id} aria-label={t('playlists.detail.addToFavorites', { title: track.title })} />
)} )}
{isHovered && canRemoveTracks && onTrackRemoved && ( {isHovered && canRemoveTracks && onTrackRemoved && (
<RemoveTrackButton <RemoveTrackButton

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test'; import { fn } from 'storybook/test';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import { import {
SharePlaylistModal, SharePlaylistModal,

View file

@ -6,6 +6,7 @@ import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { LoadingSpinner } from '@/components/ui/loading-spinner'; import { LoadingSpinner } from '@/components/ui/loading-spinner';
import { useTranslation } from '@/hooks/useTranslation';
import type { CreatePlaylistFormData } from './schema'; import type { CreatePlaylistFormData } from './schema';
interface CreatePlaylistDialogFormProps { interface CreatePlaylistDialogFormProps {
@ -31,21 +32,22 @@ export function CreatePlaylistDialogForm({
isSubmitting, isSubmitting,
isPublic, isPublic,
}: CreatePlaylistDialogFormProps) { }: CreatePlaylistDialogFormProps) {
const { t } = useTranslation();
return ( return (
<Dialog <Dialog
open={open} open={open}
onClose={onClose} onClose={onClose}
title="Créer une playlist" title={t('playlists.createDialog.title')}
variant="default" variant="default"
aria-label="Dialogue de création de playlist"
> >
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="title">Titre *</Label> <Label htmlFor="title">{t('playlists.createDialog.titleLabel')} *</Label>
<Input <Input
id="title" id="title"
{...register('title')} {...register('title')}
placeholder="Ma nouvelle playlist" placeholder={t('playlists.createDialog.titlePlaceholder')}
disabled={isSubmitting} disabled={isSubmitting}
aria-invalid={errors.title ? 'true' : 'false'} aria-invalid={errors.title ? 'true' : 'false'}
aria-describedby={errors.title ? 'create-title-error' : undefined} aria-describedby={errors.title ? 'create-title-error' : undefined}
@ -63,11 +65,11 @@ export function CreatePlaylistDialogForm({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="description">Description</Label> <Label htmlFor="description">{t('playlists.createDialog.descriptionLabel')}</Label>
<Textarea <Textarea
id="description" id="description"
{...register('description')} {...register('description')}
placeholder="Décrivez votre playlist..." placeholder={t('playlists.createDialog.descriptionPlaceholder')}
rows={3} rows={3}
disabled={isSubmitting} disabled={isSubmitting}
aria-invalid={errors.description ? 'true' : 'false'} aria-invalid={errors.description ? 'true' : 'false'}
@ -95,7 +97,7 @@ export function CreatePlaylistDialogForm({
aria-checked={isPublic} aria-checked={isPublic}
/> />
<Label htmlFor="is_public" className="cursor-pointer"> <Label htmlFor="is_public" className="cursor-pointer">
Playlist publique {t('playlists.createDialog.publicPlaylist')}
</Label> </Label>
</div> </div>
@ -105,19 +107,19 @@ export function CreatePlaylistDialogForm({
variant="secondary" variant="secondary"
onClick={onCancel} onClick={onCancel}
disabled={isSubmitting} disabled={isSubmitting}
aria-label="Annuler la création de playlist" aria-label={t('playlists.createDialog.cancel')}
> >
Annuler {t('playlists.createDialog.cancelButton')}
</Button> </Button>
<Button <Button
type="submit" type="submit"
disabled={isSubmitting} disabled={isSubmitting}
aria-label="Créer la playlist" aria-label={t('playlists.createDialog.submit')}
> >
{isSubmitting && ( {isSubmitting && (
<LoadingSpinner size="sm" inline className="mr-2" /> <LoadingSpinner size="sm" inline className="mr-2" />
)} )}
Créer {t('playlists.createDialog.submitButton')}
</Button> </Button>
</div> </div>
</form> </form>

View file

@ -1,4 +1,5 @@
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'; import { ConfirmationDialog } from '@/components/ui/confirmation-dialog';
import { useTranslation } from '@/hooks/useTranslation';
import { usePlaylistActions } from './usePlaylistActions'; import { usePlaylistActions } from './usePlaylistActions';
import { PlaylistActionsButtons } from './PlaylistActionsButtons'; import { PlaylistActionsButtons } from './PlaylistActionsButtons';
import { PlaylistActionsEditDialog } from './PlaylistActionsEditDialog'; import { PlaylistActionsEditDialog } from './PlaylistActionsEditDialog';
@ -11,6 +12,7 @@ export function PlaylistActions({
canShare = false, canShare = false,
className, className,
}: PlaylistActionsProps) { }: PlaylistActionsProps) {
const { t } = useTranslation();
const { const {
permissions, permissions,
showEditDialog, showEditDialog,
@ -32,7 +34,7 @@ export function PlaylistActions({
} }
return ( return (
<div className={className} role="group" aria-label="Actions de la playlist"> <div className={className} role="group" aria-label={t('playlists.actions.groupLabel')}>
<PlaylistActionsButtons <PlaylistActionsButtons
canEdit={permissions.canEdit} canEdit={permissions.canEdit}
canDelete={permissions.canDelete} canDelete={permissions.canDelete}
@ -55,10 +57,10 @@ export function PlaylistActions({
open={showDeleteDialog} open={showDeleteDialog}
onClose={() => setShowDeleteDialog(false)} onClose={() => setShowDeleteDialog(false)}
onConfirm={handleDeleteConfirm} onConfirm={handleDeleteConfirm}
title="Supprimer la playlist" title={t('playlists.actions.deleteTitle')}
description={`Êtes-vous sûr de vouloir supprimer « ${playlist.title} » ? Cette action est irréversible. Tous les titres de la playlist seront retirés.`} description={t('playlists.actions.deleteConfirmation', { title: playlist.title })}
confirmLabel="Supprimer" confirmLabel={t('playlists.actions.deleteConfirm')}
cancelLabel="Annuler" cancelLabel={t('playlists.actions.deleteCancel')}
variant="destructive" variant="destructive"
isLoading={deleteMutation.isPending} isLoading={deleteMutation.isPending}
/> />

View file

@ -1,6 +1,7 @@
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Edit, Trash2, Share2, CheckCircle2 } from 'lucide-react'; import { Edit, Trash2, Share2, CheckCircle2 } from 'lucide-react';
import { LoadingSpinner } from '@/components/ui/loading-spinner'; import { LoadingSpinner } from '@/components/ui/loading-spinner';
import { useTranslation } from '@/hooks/useTranslation';
interface PlaylistActionsButtonsProps { interface PlaylistActionsButtonsProps {
canEdit: boolean; canEdit: boolean;
@ -23,6 +24,7 @@ export function PlaylistActionsButtons({
isBusy, isBusy,
showSuccess, showSuccess,
}: PlaylistActionsButtonsProps) { }: PlaylistActionsButtonsProps) {
const { t } = useTranslation();
return ( return (
<div className="flex flex-col sm:flex-row gap-2 sm:gap-2 mb-4 sm:mb-6"> <div className="flex flex-col sm:flex-row gap-2 sm:gap-2 mb-4 sm:mb-6">
{canEdit && ( {canEdit && (
@ -30,13 +32,13 @@ export function PlaylistActionsButtons({
variant="outline" variant="outline"
onClick={onEditClick} onClick={onEditClick}
disabled={isBusy} disabled={isBusy}
aria-label="Modifier la playlist" aria-label={t('playlists.actions.editPlaylist')}
className="touch-manipulation min-h-11 sm:min-h-0 w-full sm:w-auto" className="touch-manipulation min-h-11 sm:min-h-0 w-full sm:w-auto"
> >
{isBusy ? ( {isBusy ? (
<> <>
<LoadingSpinner size="sm" inline className="sm:mr-2" /> <LoadingSpinner size="sm" inline className="sm:mr-2" />
<span className="hidden sm:inline">Enregistrement...</span> <span className="hidden sm:inline">{t('playlists.actions.saving')}</span>
</> </>
) : showSuccess ? ( ) : showSuccess ? (
<> <>
@ -44,12 +46,12 @@ export function PlaylistActionsButtons({
className="w-4 h-4 sm:mr-2 text-green-600 dark:text-green-400" className="w-4 h-4 sm:mr-2 text-green-600 dark:text-green-400"
aria-hidden aria-hidden
/> />
<span className="hidden sm:inline">Enregistré</span> <span className="hidden sm:inline">{t('playlists.actions.saved')}</span>
</> </>
) : ( ) : (
<> <>
<Edit className="w-4 h-4 sm:mr-2" aria-hidden /> <Edit className="w-4 h-4 sm:mr-2" aria-hidden />
Modifier {t('playlists.actions.edit')}
</> </>
)} )}
</Button> </Button>
@ -59,11 +61,11 @@ export function PlaylistActionsButtons({
variant="outline" variant="outline"
onClick={onShareClick} onClick={onShareClick}
disabled={isBusy} disabled={isBusy}
aria-label="Partager la playlist" aria-label={t('playlists.actions.sharePlaylist')}
className="touch-manipulation min-h-11 sm:min-h-0 w-full sm:w-auto" className="touch-manipulation min-h-11 sm:min-h-0 w-full sm:w-auto"
> >
<Share2 className="w-4 h-4 sm:mr-2" aria-hidden /> <Share2 className="w-4 h-4 sm:mr-2" aria-hidden />
Partager {t('playlists.actions.share')}
</Button> </Button>
)} )}
{canDelete && ( {canDelete && (
@ -71,11 +73,11 @@ export function PlaylistActionsButtons({
variant="destructive" variant="destructive"
onClick={onDeleteClick} onClick={onDeleteClick}
disabled={isBusy} disabled={isBusy}
aria-label="Supprimer la playlist" aria-label={t('playlists.actions.deletePlaylist')}
className="touch-manipulation min-h-11 sm:min-h-0 w-full sm:w-auto" className="touch-manipulation min-h-11 sm:min-h-0 w-full sm:w-auto"
> >
<Trash2 className="w-4 h-4 sm:mr-2" aria-hidden /> <Trash2 className="w-4 h-4 sm:mr-2" aria-hidden />
Supprimer {t('playlists.actions.delete')}
</Button> </Button>
)} )}
</div> </div>

View file

@ -1,6 +1,7 @@
import { Dialog } from '@/components/ui/dialog'; import { Dialog } from '@/components/ui/dialog';
import { LoadingSpinner } from '@/components/ui/loading-spinner'; import { LoadingSpinner } from '@/components/ui/loading-spinner';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { useTranslation } from '@/hooks/useTranslation';
import type { UpdatePlaylistRequest } from '../../types'; import type { UpdatePlaylistRequest } from '../../types';
interface PlaylistActionsEditDialogProps { interface PlaylistActionsEditDialogProps {
@ -20,23 +21,24 @@ export function PlaylistActionsEditDialog({
onConfirm, onConfirm,
isPending, isPending,
}: PlaylistActionsEditDialogProps) { }: PlaylistActionsEditDialogProps) {
const { t } = useTranslation();
return ( return (
<Dialog <Dialog
open={open} open={open}
onClose={onClose} onClose={onClose}
title="Modifier la playlist" title={t('playlists.editDialog.title')}
variant="default" variant="default"
onConfirm={onConfirm} onConfirm={onConfirm}
onCancel={onClose} onCancel={onClose}
confirmLabel={isPending ? 'Enregistrement...' : 'Enregistrer'} confirmLabel={isPending ? t('playlists.editDialog.saving') : t('playlists.editDialog.save')}
cancelLabel="Annuler" cancelLabel={t('playlists.editDialog.cancel')}
showCancel showCancel
size="md" size="md"
> >
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label htmlFor="edit-title" className="block text-sm font-medium mb-2"> <label htmlFor="edit-title" className="block text-sm font-medium mb-2">
Titre {t('playlists.editDialog.titleLabel')}
</label> </label>
<Input <Input
id="edit-title" id="edit-title"
@ -44,7 +46,7 @@ export function PlaylistActionsEditDialog({
onChange={(e) => onChange={(e) =>
onFormChange({ ...editForm, title: e.target.value }) onFormChange({ ...editForm, title: e.target.value })
} }
placeholder="Titre de la playlist" placeholder={t('playlists.editDialog.titlePlaceholder')}
aria-required aria-required
/> />
</div> </div>
@ -53,7 +55,7 @@ export function PlaylistActionsEditDialog({
htmlFor="edit-description" htmlFor="edit-description"
className="block text-sm font-medium mb-2" className="block text-sm font-medium mb-2"
> >
Description {t('playlists.editDialog.descriptionLabel')}
</label> </label>
<textarea <textarea
id="edit-description" id="edit-description"
@ -63,7 +65,7 @@ export function PlaylistActionsEditDialog({
} }
className="w-full px-4 py-2 border border-input rounded-md bg-background text-foreground min-h-24" className="w-full px-4 py-2 border border-input rounded-md bg-background text-foreground min-h-24"
rows={3} rows={3}
placeholder="Description de la playlist" placeholder={t('playlists.editDialog.descriptionPlaceholder')}
/> />
</div> </div>
<div> <div>
@ -71,7 +73,7 @@ export function PlaylistActionsEditDialog({
htmlFor="edit-cover-url" htmlFor="edit-cover-url"
className="block text-sm font-medium mb-2" className="block text-sm font-medium mb-2"
> >
URL de la couverture {t('playlists.editDialog.coverUrlLabel')}
</label> </label>
<Input <Input
id="edit-cover-url" id="edit-cover-url"
@ -95,7 +97,7 @@ export function PlaylistActionsEditDialog({
aria-checked={editForm.is_public ?? false} aria-checked={editForm.is_public ?? false}
/> />
<label htmlFor="edit-is_public" className="text-sm font-medium"> <label htmlFor="edit-is_public" className="text-sm font-medium">
Playlist publique {t('playlists.editDialog.isPublic')}
</label> </label>
</div> </div>
{isPending && ( {isPending && (
@ -105,7 +107,7 @@ export function PlaylistActionsEditDialog({
aria-live="assertive" aria-live="assertive"
> >
<LoadingSpinner size="sm" inline /> <LoadingSpinner size="sm" inline />
<span>Enregistrement en cours...</span> <span>{t('playlists.editDialog.savingInProgress')}</span>
</div> </div>
)} )}
</div> </div>

View file

@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useUpdatePlaylist, useDeletePlaylist } from '../../hooks/usePlaylist'; import { useUpdatePlaylist, useDeletePlaylist } from '../../hooks/usePlaylist';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/hooks/useTranslation';
import { usePlaylistPermissions } from '../../hooks/usePlaylistPermissions'; import { usePlaylistPermissions } from '../../hooks/usePlaylistPermissions';
import type { Playlist, UpdatePlaylistRequest } from '../../types'; import type { Playlist, UpdatePlaylistRequest } from '../../types';
@ -17,6 +18,7 @@ export function usePlaylistActions({
onUpdated, onUpdated,
canShare: _canShare = false, canShare: _canShare = false,
}: UsePlaylistActionsOptions) { }: UsePlaylistActionsOptions) {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { success: showSuccessToast, error: showError } = useToast(); const { success: showSuccessToast, error: showError } = useToast();
const permissions = usePlaylistPermissions(playlist); const permissions = usePlaylistPermissions(playlist);
@ -62,14 +64,14 @@ export function usePlaylistActions({
id: playlist.id, id: playlist.id,
data: editForm, data: editForm,
}); });
showSuccessToast('Playlist mise à jour avec succès'); showSuccessToast(t('playlists.actions.updateSuccess'));
setShowEditDialog(false); setShowEditDialog(false);
onUpdated?.(); onUpdated?.();
} catch (error) { } catch (error) {
showError( showError(
error instanceof Error error instanceof Error
? error.message ? error.message
: 'Erreur lors de la mise à jour', : t('playlists.actions.updateError'),
); );
} }
}; };
@ -77,14 +79,14 @@ export function usePlaylistActions({
const handleDeleteConfirm = async () => { const handleDeleteConfirm = async () => {
try { try {
await deleteMutation.mutateAsync(playlist.id); await deleteMutation.mutateAsync(playlist.id);
showSuccessToast('Playlist supprimée avec succès'); showSuccessToast(t('playlists.actions.deleteSuccess'));
setShowDeleteDialog(false); setShowDeleteDialog(false);
navigate('/playlists'); navigate('/playlists');
} catch (error) { } catch (error) {
showError( showError(
error instanceof Error error instanceof Error
? error.message ? error.message
: 'Erreur lors de la suppression', : t('playlists.actions.deleteError'),
); );
} }
}; };

View file

@ -1,5 +1,6 @@
import { Library } from 'lucide-react'; import { Library } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from '@/hooks/useTranslation';
import type { PlaylistListEmptyVariant } from './types'; import type { PlaylistListEmptyVariant } from './types';
interface PlaylistListEmptyProps { interface PlaylistListEmptyProps {
@ -8,6 +9,8 @@ interface PlaylistListEmptyProps {
} }
export function PlaylistListEmpty({ variant, className }: PlaylistListEmptyProps) { export function PlaylistListEmpty({ variant, className }: PlaylistListEmptyProps) {
const { t } = useTranslation();
if (variant === 'no_playlists') { if (variant === 'no_playlists') {
return ( return (
<div <div
@ -16,9 +19,9 @@ export function PlaylistListEmpty({ variant, className }: PlaylistListEmptyProps
aria-live="polite" aria-live="polite"
> >
<Library className="h-12 w-12 mx-auto mb-4 text-muted-foreground opacity-50" /> <Library className="h-12 w-12 mx-auto mb-4 text-muted-foreground opacity-50" />
<h3 className="text-lg font-semibold mb-2">No playlists yet</h3> <h3 className="text-lg font-semibold mb-2">{t('playlists.emptyTitle')}</h3>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Start by creating your first playlist to organize your tracks. {t('playlists.emptyDescription')}
</p> </p>
</div> </div>
); );
@ -31,10 +34,9 @@ export function PlaylistListEmpty({ variant, className }: PlaylistListEmptyProps
aria-live="polite" aria-live="polite"
> >
<Library className="h-12 w-12 mx-auto mb-4 text-muted-foreground opacity-50" /> <Library className="h-12 w-12 mx-auto mb-4 text-muted-foreground opacity-50" />
<h3 className="text-lg font-semibold mb-2">No playlists found</h3> <h3 className="text-lg font-semibold mb-2">{t('search.noResults')}</h3>
<p className="text-muted-foreground mb-4"> <p className="text-muted-foreground mb-4">
No playlists match your search criteria. Try adjusting your filters or {t('search.noResultsHint')}
search terms.
</p> </p>
</div> </div>
); );

View file

@ -12,6 +12,7 @@ import { usePlaylistTrackList } from './usePlaylistTrackList';
import { PlaylistTrackListEmpty } from './PlaylistTrackListEmpty'; import { PlaylistTrackListEmpty } from './PlaylistTrackListEmpty';
import { PlaylistTrackListSortableItem } from './PlaylistTrackListSortableItem'; import { PlaylistTrackListSortableItem } from './PlaylistTrackListSortableItem';
import { PlaylistTrackListSkeleton } from './PlaylistTrackListSkeleton'; import { PlaylistTrackListSkeleton } from './PlaylistTrackListSkeleton';
import { useTranslation } from '@/hooks/useTranslation';
import type { PlaylistTrackListProps } from './types'; import type { PlaylistTrackListProps } from './types';
export function PlaylistTrackList({ export function PlaylistTrackList({
@ -25,13 +26,14 @@ export function PlaylistTrackList({
isPlaying, isPlaying,
currentPlayingId, currentPlayingId,
className, className,
emptyMessage = 'Aucun track dans cette playlist', emptyMessage,
emptyDescription = 'Ajoutez des tracks à cette playlist pour commencer.', emptyDescription,
enableDragAndDrop = true, enableDragAndDrop = true,
canRemoveTracks = true, canRemoveTracks = true,
isLoading = false, isLoading = false,
isFavorisPlaylist = false, isFavorisPlaylist = false,
}: PlaylistTrackListProps) { }: PlaylistTrackListProps) {
const { t } = useTranslation();
const { const {
sortedPlaylistTracks, sortedPlaylistTracks,
trackMap, trackMap,
@ -67,7 +69,7 @@ export function PlaylistTrackList({
const listClassName = cn('space-y-1', className); const listClassName = cn('space-y-1', className);
const listProps = { const listProps = {
role: 'list' as const, role: 'list' as const,
'aria-label': 'Liste des tracks de la playlist', 'aria-label': t('playlists.trackListLabel'),
}; };
if (!enableDragAndDrop) { if (!enableDragAndDrop) {

View file

@ -3,6 +3,7 @@
*/ */
import { Music } from 'lucide-react'; import { Music } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from '@/hooks/useTranslation';
interface PlaylistTrackListEmptyProps { interface PlaylistTrackListEmptyProps {
message?: string; message?: string;
@ -11,10 +12,13 @@ interface PlaylistTrackListEmptyProps {
} }
export function PlaylistTrackListEmpty({ export function PlaylistTrackListEmpty({
message = 'Aucun track dans cette playlist', message,
description = 'Ajoutez des tracks à cette playlist pour commencer.', description,
className, className,
}: PlaylistTrackListEmptyProps) { }: PlaylistTrackListEmptyProps) {
const { t } = useTranslation();
const displayMessage = message ?? t('playlists.detail.emptyTracks');
const displayDescription = description ?? t('playlists.detail.emptyTracksDescription');
return ( return (
<div <div
className={cn( className={cn(
@ -24,11 +28,11 @@ export function PlaylistTrackListEmpty({
> >
<Music className="h-12 w-12 text-muted-foreground mb-4 opacity-50" /> <Music className="h-12 w-12 text-muted-foreground mb-4 opacity-50" />
<p className="text-lg font-medium text-foreground mb-2"> <p className="text-lg font-medium text-foreground mb-2">
{message} {displayMessage}
</p> </p>
{description && ( {displayDescription && (
<p className="text-sm text-muted-foreground max-w-md"> <p className="text-sm text-muted-foreground max-w-md">
{description} {displayDescription}
</p> </p>
)} )}
</div> </div>

View file

@ -3,6 +3,7 @@
*/ */
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from '@/hooks/useTranslation';
interface PlaylistTrackListSkeletonProps { interface PlaylistTrackListSkeletonProps {
count?: number; count?: number;
@ -13,11 +14,12 @@ export function PlaylistTrackListSkeleton({
count = 5, count = 5,
className, className,
}: PlaylistTrackListSkeletonProps) { }: PlaylistTrackListSkeletonProps) {
const { t } = useTranslation();
return ( return (
<div <div
className={cn('space-y-1', className)} className={cn('space-y-1', className)}
role="status" role="status"
aria-label="Chargement des pistes" aria-label={t('playlists.detail.loadingTracks')}
> >
{Array.from({ length: count }).map((_, index) => ( {Array.from({ length: count }).map((_, index) => (
<div <div
@ -44,7 +46,7 @@ export function PlaylistTrackListSkeleton({
</div> </div>
</div> </div>
))} ))}
<span className="sr-only">Chargement des pistes en cours...</span> <span className="sr-only">{t('playlists.detail.loadingTracksProgress')}</span>
</div> </div>
); );
} }

View file

@ -15,6 +15,7 @@ import {
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { useReorderPlaylistTracks } from '../../hooks/usePlaylist'; import { useReorderPlaylistTracks } from '../../hooks/usePlaylist';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/hooks/useTranslation';
import { sortTracksByPosition } from './utils'; import { sortTracksByPosition } from './utils';
import type { PlaylistTrack, Track } from '../../types'; import type { PlaylistTrack, Track } from '../../types';
@ -37,6 +38,7 @@ export function usePlaylistTrackList(
[tracks], [tracks],
); );
const { t } = useTranslation();
const { toast, error: showError } = useToast(); const { toast, error: showError } = useToast();
const reorderMutation = useReorderPlaylistTracks(); const reorderMutation = useReorderPlaylistTracks();
@ -67,11 +69,11 @@ export function usePlaylistTrackList(
playlistId: String(playlistId), playlistId: String(playlistId),
trackIds, trackIds,
}); });
toast({ message: 'Playlist réorganisée', type: 'success' }); toast({ message: t('playlists.detail.playlistReordered'), type: 'success' });
onTracksReordered?.(); onTracksReordered?.();
} catch { } catch {
setSortedPlaylistTracks(sortTracksByPosition(playlistTracks)); setSortedPlaylistTracks(sortTracksByPosition(playlistTracks));
showError('Impossible de réorganiser la playlist. Veuillez réessayer.'); showError(t('playlists.detail.reorderError'));
} }
}, },
[ [
@ -82,6 +84,7 @@ export function usePlaylistTrackList(
toast, toast,
showError, showError,
onTracksReordered, onTracksReordered,
t,
], ],
); );

View file

@ -11,7 +11,7 @@ import type {
CreatePlaylistRequest, CreatePlaylistRequest,
UpdatePlaylistRequest, UpdatePlaylistRequest,
} from '../types'; } from '../types';
import { TokenStorage } from '@/services/tokenStorage'; import { useAuthStore } from '@/features/auth/store/authStore';
import { useUser } from '@/features/auth/hooks/useUser'; import { useUser } from '@/features/auth/hooks/useUser';
// Create Playlist // Create Playlist
@ -183,13 +183,14 @@ export function usePlaylists(
sortBy?: 'created_at' | 'title' | 'track_count', sortBy?: 'created_at' | 'title' | 'track_count',
sortOrder?: 'asc' | 'desc', sortOrder?: 'asc' | 'desc',
) { ) {
// Check if token exists in storage (more reliable than waiting for store hydration) // With httpOnly cookies, TokenStorage.getAccessToken() is always null.
const hasToken = !!TokenStorage.getAccessToken(); // Use auth store's isAuthenticated flag instead.
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const page = Math.floor(offset / limit) + 1; const page = Math.floor(offset / limit) + 1;
return useQuery({ return useQuery({
queryKey: ['playlists', limit, offset, sortBy, sortOrder], queryKey: ['playlists', limit, offset, sortBy, sortOrder],
queryFn: () => listPlaylists(page, limit, undefined, sortBy, sortOrder), queryFn: () => listPlaylists(page, limit, undefined, sortBy, sortOrder),
enabled: hasToken, // Only fetch if token exists enabled: isAuthenticated,
}); });
} }

View file

@ -6,7 +6,7 @@
* FE-PAGE-009: Complete Playlist List page implementation * FE-PAGE-009: Complete Playlist List page implementation
*/ */
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { PlaylistList } from '../components/PlaylistList'; import { PlaylistList } from '../components/PlaylistList';
import { CreatePlaylistDialog } from '../components/CreatePlaylistDialog'; import { CreatePlaylistDialog } from '../components/CreatePlaylistDialog';
import { ImportPlaylistButton } from '../components/ImportPlaylistButton'; import { ImportPlaylistButton } from '../components/ImportPlaylistButton';
@ -22,6 +22,7 @@ import {
X, X,
ArrowUpDown, ArrowUpDown,
} from 'lucide-react'; } from 'lucide-react';
import { useTranslation } from '@/hooks/useTranslation';
import '../styles/playlists.mobile.css'; import '../styles/playlists.mobile.css';
// FE-PAGE-009: Complete Playlist List page implementation // FE-PAGE-009: Complete Playlist List page implementation
@ -30,11 +31,16 @@ type SortField = 'created_at' | 'title' | 'track_count';
type SortOrder = 'asc' | 'desc'; type SortOrder = 'asc' | 'desc';
export function PlaylistListPage() { export function PlaylistListPage() {
const { t } = useTranslation();
const [enableSelection, setEnableSelection] = useState(false); const [enableSelection, setEnableSelection] = useState(false);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [showFilters, setShowFilters] = useState(false); const [showFilters, setShowFilters] = useState(false);
useEffect(() => {
document.title = t('playlists.pageTitle');
}, [t]);
// FE-PAGE-009: Filtering state // FE-PAGE-009: Filtering state
const [filterIsPublic, setFilterIsPublic] = useState<boolean | undefined>( const [filterIsPublic, setFilterIsPublic] = useState<boolean | undefined>(
undefined, undefined,
@ -63,10 +69,10 @@ export function PlaylistListPage() {
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 sm:gap-0"> <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 sm:gap-0">
<div> <div>
<h1 className="text-heading-1 font-heading text-foreground"> <h1 className="text-heading-1 font-heading text-foreground">
Playlists {t('playlists.title')}
</h1> </h1>
<p className="text-sm sm:text-base text-muted-foreground"> <p className="text-sm sm:text-base text-muted-foreground">
Découvrez et gérez vos playlists {t('playlists.subtitle')}
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@ -75,12 +81,12 @@ export function PlaylistListPage() {
size="sm" size="sm"
onClick={() => setIsCreateDialogOpen(true)} onClick={() => setIsCreateDialogOpen(true)}
className="touch-manipulation min-h-11 sm:min-h-0" className="touch-manipulation min-h-11 sm:min-h-0"
aria-label="Créer une nouvelle playlist" aria-label={t('playlists.createNewPlaylist')}
data-testid="create-playlist-btn" data-testid="create-playlist-btn"
> >
<Plus className="h-4 w-4 sm:mr-2" aria-hidden="true" /> <Plus className="h-4 w-4 sm:mr-2" aria-hidden="true" />
<span className="hidden sm:inline">Créer</span> <span className="hidden sm:inline">{t('playlists.createButton')}</span>
<span className="sm:hidden">Nouvelle</span> <span className="sm:hidden">{t('playlists.createButtonMobile')}</span>
</Button> </Button>
<ImportPlaylistButton className="touch-manipulation min-h-11 sm:min-h-0" /> <ImportPlaylistButton className="touch-manipulation min-h-11 sm:min-h-0" />
<Button <Button
@ -90,13 +96,13 @@ export function PlaylistListPage() {
className="touch-manipulation min-h-11 sm:min-h-0" className="touch-manipulation min-h-11 sm:min-h-0"
aria-label={ aria-label={
enableSelection enableSelection
? 'Désactiver la sélection' ? t('playlists.disableSelection')
: 'Activer la sélection' : t('playlists.enableSelection')
} }
> >
<CheckSquare className="h-4 w-4 sm:mr-2" aria-hidden="true" /> <CheckSquare className="h-4 w-4 sm:mr-2" aria-hidden="true" />
<span className="hidden sm:inline"> <span className="hidden sm:inline">
{enableSelection ? 'Annuler' : 'Sélectionner'} {enableSelection ? t('playlists.deselectButton') : t('playlists.selectButton')}
</span> </span>
</Button> </Button>
</div> </div>
@ -113,7 +119,8 @@ export function PlaylistListPage() {
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Rechercher des playlists..." placeholder={t('playlists.searchPlaceholder')}
aria-label={t('playlists.searchPlaceholder')}
className="pl-10" className="pl-10"
data-testid="playlist-search" data-testid="playlist-search"
/> />
@ -123,17 +130,17 @@ export function PlaylistListPage() {
onClick={() => setShowFilters(!showFilters)} onClick={() => setShowFilters(!showFilters)}
> >
<Filter className="mr-2 h-4 w-4" /> <Filter className="mr-2 h-4 w-4" />
Filters {t('playlists.filtersButton')}
{hasActiveFilters && ( {hasActiveFilters && (
<span className="ml-2 bg-primary text-foreground rounded-full px-2 py-0.5 text-xs"> <span className="ml-2 bg-primary text-foreground rounded-full px-2 py-0.5 text-xs">
Active {t('playlists.filtersActive')}
</span> </span>
)} )}
</Button> </Button>
{hasActiveFilters && ( {hasActiveFilters && (
<Button variant="ghost" onClick={handleClearFilters}> <Button variant="ghost" onClick={handleClearFilters}>
<X className="mr-2 h-4 w-4" /> <X className="mr-2 h-4 w-4" />
Clear {t('playlists.clearFilters')}
</Button> </Button>
)} )}
</div> </div>
@ -141,7 +148,7 @@ export function PlaylistListPage() {
{showFilters && ( {showFilters && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-4 border-t"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-4 border-t">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Visibility</label> <label className="text-sm font-medium">{t('playlists.filterVisibility')}</label>
<Select <Select
value={ value={
filterIsPublic === undefined filterIsPublic === undefined
@ -156,14 +163,14 @@ export function PlaylistListPage() {
else setFilterIsPublic(false); else setFilterIsPublic(false);
}} }}
options={[ options={[
{ value: 'all', label: 'All' }, { value: 'all', label: t('playlists.all') },
{ value: 'public', label: 'Public' }, { value: 'public', label: t('playlists.public') },
{ value: 'private', label: 'Private' }, { value: 'private', label: t('playlists.private') },
]} ]}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Owner</label> <label className="text-sm font-medium">{t('playlists.filterOwner')}</label>
<Select <Select
value={filterOwner} value={filterOwner}
onChange={(value) => onChange={(value) =>
@ -175,14 +182,14 @@ export function PlaylistListPage() {
) )
} }
options={[ options={[
{ value: 'all', label: 'All' }, { value: 'all', label: t('playlists.all') },
{ value: 'mine', label: 'My Playlists' }, { value: 'mine', label: t('playlists.myPlaylists') },
{ value: 'others', label: 'Others' }, { value: 'others', label: t('playlists.others') },
]} ]}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Sort By</label> <label className="text-sm font-medium">{t('playlists.filterSortBy')}</label>
<div className="flex gap-2"> <div className="flex gap-2">
<Select <Select
value={sortBy} value={sortBy}
@ -194,9 +201,9 @@ export function PlaylistListPage() {
) )
} }
options={[ options={[
{ value: 'created_at', label: 'Date' }, { value: 'created_at', label: t('playlists.sortByDate') },
{ value: 'title', label: 'Title' }, { value: 'title', label: t('playlists.sortByTitle') },
{ value: 'track_count', label: 'Tracks' }, { value: 'track_count', label: t('playlists.sortByTracks') },
]} ]}
className="flex-1" className="flex-1"
/> />
@ -208,6 +215,7 @@ export function PlaylistListPage() {
prev === 'asc' ? 'desc' : 'asc', prev === 'asc' ? 'desc' : 'asc',
) )
} }
aria-label={t('playlists.sortToggle')}
> >
<ArrowUpDown className="h-4 w-4" /> <ArrowUpDown className="h-4 w-4" />
</Button> </Button>

View file

@ -1,6 +1,7 @@
/** /**
* SharedPlaylistPage public view of a playlist via share token (v0.10.4 F143) * SharedPlaylistPage public view of a playlist via share token (v0.10.4 F143)
* No auth required; read-only display of playlist and tracks. * No auth required; read-only display of playlist and tracks.
* Renders standalone (no DashboardLayout) to avoid auth-required API calls.
*/ */
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
@ -8,6 +9,11 @@ import { Play, Shuffle, Copy } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card'; import { Card } from '@/components/ui/card';
import { playlistsApi } from '@/services/api/playlists'; import { playlistsApi } from '@/services/api/playlists';
import { usePlayerStore } from '@/features/player/store/playerStore';
import { GlobalPlayer } from '@/features/player/components/GlobalPlayer';
import type { Track as PlayerTrack } from '@/features/player/types';
import type { Track as ApiTrack } from '@/features/tracks/types/track';
import { useTranslation } from '@/hooks/useTranslation';
import { PlaylistDetailPageHero } from './playlist-detail-page/PlaylistDetailPageHero'; import { PlaylistDetailPageHero } from './playlist-detail-page/PlaylistDetailPageHero';
import { PlaylistDetailPageCoverAndInfo } from './playlist-detail-page/PlaylistDetailPageCoverAndInfo'; import { PlaylistDetailPageCoverAndInfo } from './playlist-detail-page/PlaylistDetailPageCoverAndInfo';
import { PlaylistDetailPageSkeleton } from './playlist-detail-page/PlaylistDetailPageSkeleton'; import { PlaylistDetailPageSkeleton } from './playlist-detail-page/PlaylistDetailPageSkeleton';
@ -15,8 +21,23 @@ import { PlaylistDetailPageNotFound } from './playlist-detail-page/PlaylistDetai
import { PlaylistTrackList } from '../components/PlaylistTrackList'; import { PlaylistTrackList } from '../components/PlaylistTrackList';
import toast from '@/utils/toast'; import toast from '@/utils/toast';
function mapToPlayerTrack(t: ApiTrack): PlayerTrack {
return {
id: t.id,
title: t.title,
artist: t.artist,
album: t.album,
duration: t.duration,
url: (t as { stream_manifest_url?: string }).stream_manifest_url || `/api/v1/tracks/${t.id}/download`,
cover: (t as { cover_art_path?: string }).cover_art_path,
genre: t.genre,
};
}
export function SharedPlaylistPage() { export function SharedPlaylistPage() {
const { token } = useParams<{ token: string }>(); const { token } = useParams<{ token: string }>();
const { t } = useTranslation();
const { play, addToQueue, clearQueue, toggleShuffle } = usePlayerStore();
const { data: playlist, isLoading, error } = useQuery({ const { data: playlist, isLoading, error } = useQuery({
queryKey: ['playlistShared', token], queryKey: ['playlistShared', token],
@ -25,23 +46,48 @@ export function SharedPlaylistPage() {
}); });
if (isLoading) { if (isLoading) {
return <PlaylistDetailPageSkeleton />; return (
<div className="min-h-screen bg-background">
<PlaylistDetailPageSkeleton />
</div>
);
} }
if (error || !playlist) { if (error || !playlist) {
return <PlaylistDetailPageNotFound />; return (
<div className="min-h-screen bg-background">
<PlaylistDetailPageNotFound />
</div>
);
} }
const playlistTracks = playlist.tracks ?? []; const playlistTracks = playlist.tracks ?? [];
const tracks = playlistTracks.map((pt) => pt.track).filter((t): t is NonNullable<typeof t> => Boolean(t)); const tracks = playlistTracks.map((pt) => pt.track).filter((t): t is NonNullable<typeof t> => Boolean(t));
const handlePlayAll = () => {
if (tracks.length === 0) return;
const playerTracks = tracks.map(mapToPlayerTrack);
clearQueue();
addToQueue(playerTracks);
play(playerTracks[0]);
};
const handleShuffle = () => {
if (tracks.length === 0) return;
const playerTracks = tracks.map(mapToPlayerTrack);
clearQueue();
addToQueue(playerTracks);
play(playerTracks[0]);
toggleShuffle();
};
const handleCopyLink = () => { const handleCopyLink = () => {
navigator.clipboard.writeText(window.location.href); navigator.clipboard.writeText(window.location.href);
toast.success('Link copied to clipboard'); toast.success(t('playlists.shared.linkCopied'));
}; };
return ( return (
<div className="min-h-layout-page pb-24"> <div className="min-h-screen bg-background pb-32 relative">
<PlaylistDetailPageHero playlist={playlist} /> <PlaylistDetailPageHero playlist={playlist} />
<div className="container mx-auto px-4 md:px-8 relative -mt-40 z-10"> <div className="container mx-auto px-4 md:px-8 relative -mt-40 z-10">
<PlaylistDetailPageCoverAndInfo playlist={playlist} /> <PlaylistDetailPageCoverAndInfo playlist={playlist} />
@ -49,15 +95,19 @@ export function SharedPlaylistPage() {
<Button <Button
size="lg" size="lg"
className="rounded-full h-14 px-8 text-lg font-bold shadow-sm transition-all duration-[var(--sumi-duration-normal)] bg-primary text-primary-foreground" className="rounded-full h-14 px-8 text-lg font-bold shadow-sm transition-all duration-[var(--sumi-duration-normal)] bg-primary text-primary-foreground"
onClick={handlePlayAll}
aria-label={t('playlists.shared.playAll')}
> >
<Play className="w-5 h-5 mr-2 fill-current" /> Play All <Play className="w-5 h-5 mr-2 fill-current" /> {t('playlists.shared.playAll')}
</Button> </Button>
<Button <Button
size="lg" size="lg"
variant="outline" variant="outline"
className="rounded-full h-14 px-6 border-white/10 hover:bg-white/5 backdrop-blur-sm transition-colors duration-[var(--duration-fast)]" className="rounded-full h-14 px-6 border-white/10 hover:bg-white/5 backdrop-blur-sm transition-colors duration-[var(--duration-fast)]"
onClick={handleShuffle}
aria-label={t('playlists.shared.shuffle')}
> >
<Shuffle className="w-5 h-5 mr-2" /> Shuffle <Shuffle className="w-5 h-5 mr-2" /> {t('playlists.shared.shuffle')}
</Button> </Button>
<div className="flex-1" /> <div className="flex-1" />
<Button <Button
@ -65,14 +115,15 @@ export function SharedPlaylistPage() {
variant="outline" variant="outline"
className="rounded-full border-white/10 hover:bg-white/5" className="rounded-full border-white/10 hover:bg-white/5"
onClick={handleCopyLink} onClick={handleCopyLink}
aria-label={t('playlists.shared.copyLink')}
> >
<Copy className="w-5 h-5 mr-2" /> Copy link <Copy className="w-5 h-5 mr-2" /> {t('playlists.shared.copyLink')}
</Button> </Button>
</div> </div>
<Card variant="glass" className="overflow-hidden border-white/5"> <Card variant="glass" className="overflow-hidden border-white/5">
<div className="p-4 border-b border-white/5 bg-black/20"> <div className="p-4 border-b border-white/5 bg-black/20">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Shared playlist · {tracks.length} track{tracks.length !== 1 ? 's' : ''} {t('playlists.shared.sharedPlaylist')} · {t('playlists.shared.trackCount', { count: tracks.length })}
</p> </p>
</div> </div>
<div className="p-0"> <div className="p-0">
@ -84,11 +135,16 @@ export function SharedPlaylistPage() {
onTracksReordered={() => {}} onTracksReordered={() => {}}
enableDragAndDrop={false} enableDragAndDrop={false}
canRemoveTracks={false} canRemoveTracks={false}
emptyMessage={t('playlists.shared.noTracks')}
emptyDescription={t('playlists.shared.noTracksDescription')}
className="divide-y divide-white/5" className="divide-y divide-white/5"
/> />
</div> </div>
</Card> </Card>
</div> </div>
<div className="fixed bottom-0 left-0 right-0 z-50">
<GlobalPlayer />
</div>
</div> </div>
); );
} }

View file

@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { PlaylistDetailPageHero } from './PlaylistDetailPageHero'; import { PlaylistDetailPageHero } from './PlaylistDetailPageHero';
import { PlaylistDetailPageCoverAndInfo } from './PlaylistDetailPageCoverAndInfo'; import { PlaylistDetailPageCoverAndInfo } from './PlaylistDetailPageCoverAndInfo';
import { PlaylistDetailPageActionsBar } from './PlaylistDetailPageActionsBar'; import { PlaylistDetailPageActionsBar } from './PlaylistDetailPageActionsBar';
@ -32,6 +33,12 @@ export function PlaylistDetailPage(props?: PlaylistDetailPageProps) {
onCollaboratorAdded, onCollaboratorAdded,
} = usePlaylistDetailPage(playlistIdOverride); } = usePlaylistDetailPage(playlistIdOverride);
useEffect(() => {
if (playlist?.title) {
document.title = `${playlist.title} — Veza`;
}
}, [playlist?.title]);
if (isLoading) { if (isLoading) {
return <PlaylistDetailPageSkeleton />; return <PlaylistDetailPageSkeleton />;
} }

View file

@ -1,6 +1,7 @@
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Play, Shuffle } from 'lucide-react'; import { Play, Shuffle } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useTranslation } from '@/hooks/useTranslation';
import { PlaylistActions } from '../../components/playlist-actions'; import { PlaylistActions } from '../../components/playlist-actions';
import { PlaylistFollowButton } from '../../components/PlaylistFollowButton'; import { PlaylistFollowButton } from '../../components/PlaylistFollowButton';
import { DuplicatePlaylistButton } from '../../components/DuplicatePlaylistButton'; import { DuplicatePlaylistButton } from '../../components/DuplicatePlaylistButton';
@ -20,6 +21,7 @@ export function PlaylistDetailPageActionsBar({
onShareClick, onShareClick,
onRefetch, onRefetch,
}: PlaylistDetailPageActionsBarProps) { }: PlaylistDetailPageActionsBarProps) {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const followerCount = (playlist as { follower_count?: number }).follower_count ?? 0; const followerCount = (playlist as { follower_count?: number }).follower_count ?? 0;
const isFollowing = (playlist as { is_following?: boolean }).is_following ?? false; const isFollowing = (playlist as { is_following?: boolean }).is_following ?? false;
@ -30,14 +32,14 @@ export function PlaylistDetailPageActionsBar({
size="lg" size="lg"
className="rounded-full h-14 px-8 text-lg font-bold shadow-sm transition-all duration-[var(--sumi-duration-normal)] bg-primary text-primary-foreground" className="rounded-full h-14 px-8 text-lg font-bold shadow-sm transition-all duration-[var(--sumi-duration-normal)] bg-primary text-primary-foreground"
> >
<Play className="w-5 h-5 mr-2 fill-current" /> Play All <Play className="w-5 h-5 mr-2 fill-current" /> {t('playlists.shared.playAll')}
</Button> </Button>
<Button <Button
size="lg" size="lg"
variant="outline" variant="outline"
className="rounded-full h-14 px-6 border-white/10 hover:bg-white/5 backdrop-blur-sm transition-colors duration-[var(--duration-fast)]" className="rounded-full h-14 px-6 border-white/10 hover:bg-white/5 backdrop-blur-sm transition-colors duration-[var(--duration-fast)]"
> >
<Shuffle className="w-5 h-5 mr-2" /> Shuffle <Shuffle className="w-5 h-5 mr-2" /> {t('playlists.shared.shuffle')}
</Button> </Button>
<div className="flex-1" /> <div className="flex-1" />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View file

@ -1,8 +1,9 @@
import { Music, Calendar, Users } from 'lucide-react'; import { Music, Calendar, Users } from 'lucide-react';
import { format } from 'date-fns'; import { format, isValid } from 'date-fns';
import { Card } from '@/components/ui/card'; import { Card } from '@/components/ui/card';
import { Avatar } from '@/components/ui/avatar'; import { Avatar } from '@/components/ui/avatar';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from '@/hooks/useTranslation';
import type { Playlist } from '../../types'; import type { Playlist } from '../../types';
interface PlaylistDetailPageCoverAndInfoProps { interface PlaylistDetailPageCoverAndInfoProps {
@ -10,7 +11,11 @@ interface PlaylistDetailPageCoverAndInfoProps {
} }
export function PlaylistDetailPageCoverAndInfo({ playlist }: PlaylistDetailPageCoverAndInfoProps) { export function PlaylistDetailPageCoverAndInfo({ playlist }: PlaylistDetailPageCoverAndInfoProps) {
const { t } = useTranslation();
const followerCount = (playlist as { follower_count?: number }).follower_count ?? 0; const followerCount = (playlist as { follower_count?: number }).follower_count ?? 0;
const updatedDate = new Date(playlist.updated_at);
const formattedDate = isValid(updatedDate) ? format(updatedDate, 'MMM d, yyyy') : '';
const username = playlist.user?.username ?? '';
return ( return (
<div className="flex flex-col md:flex-row gap-8 items-end"> <div className="flex flex-col md:flex-row gap-8 items-end">
@ -41,11 +46,13 @@ export function PlaylistDetailPageCoverAndInfo({ playlist }: PlaylistDetailPageC
: 'bg-warning/10 text-warning border-warning/20' : 'bg-warning/10 text-warning border-warning/20'
)} )}
> >
{playlist.is_public ? 'Public Signal' : 'Encrypted'} {playlist.is_public ? t('playlists.shared.publicSignal') : t('playlists.shared.encrypted')}
</span>
<span className="text-xs text-muted-foreground/80 font-mono flex items-center gap-1">
<Calendar className="w-3 h-3" /> Updated {format(new Date(playlist.updated_at), 'MMM d, yyyy')}
</span> </span>
{formattedDate && (
<span className="text-xs text-muted-foreground/80 font-mono flex items-center gap-1">
<Calendar className="w-3 h-3" /> {t('playlists.shared.updated', { date: formattedDate })}
</span>
)}
</div> </div>
<h1 className="text-4xl md:text-6xl font-heading font-bold text-foreground mb-4 tracking-tight drop-shadow-lg"> <h1 className="text-4xl md:text-6xl font-heading font-bold text-foreground mb-4 tracking-tight drop-shadow-lg">
@ -62,17 +69,17 @@ export function PlaylistDetailPageCoverAndInfo({ playlist }: PlaylistDetailPageC
<div className="flex items-center gap-2 text-white/90"> <div className="flex items-center gap-2 text-white/90">
<Avatar <Avatar
className="w-6 h-6 border border-white/20" className="w-6 h-6 border border-white/20"
fallback="U" fallback={username.charAt(0).toUpperCase() || '?'}
src={playlist.user?.avatar_url} src={playlist.user?.avatar_url}
/> />
<span className="font-semibold">{playlist.user?.username}</span> <span className="font-semibold">{username}</span>
</div> </div>
<span className="text-white/30"></span> <span className="text-white/30"></span>
<span className="text-white/80">{playlist.track_count} tracks</span> <span className="text-white/80">{playlist.track_count} {t('playlists.tracks')}</span>
<span className="text-white/30"></span> <span className="text-white/30"></span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Users className="w-4 h-4 text-muted-foreground" /> <Users className="w-4 h-4 text-muted-foreground" />
<span className="text-white/80">{followerCount} followers</span> <span className="text-white/80">{t('playlists.shared.followers', { count: followerCount })}</span>
</div> </div>
</div> </div>
</div> </div>

View file

@ -4,15 +4,26 @@ interface PlaylistDetailPageHeroProps {
playlist: Playlist; playlist: Playlist;
} }
function isSafeUrl(url: string): boolean {
try {
const parsed = new URL(url, window.location.origin);
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
} catch {
return false;
}
}
export function PlaylistDetailPageHero({ playlist }: PlaylistDetailPageHeroProps) { export function PlaylistDetailPageHero({ playlist }: PlaylistDetailPageHeroProps) {
const safeUrl = playlist.cover_url && isSafeUrl(playlist.cover_url) ? playlist.cover_url : null;
return ( return (
<div className="relative h-80 md:h-96 w-full overflow-hidden"> <div className="relative h-80 md:h-96 w-full overflow-hidden" aria-hidden="true">
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-background to-secondary/30" /> <div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-background to-secondary/30" />
{playlist.cover_url && ( {safeUrl && (
<div <div
className="absolute inset-0 opacity-30 blur-3xl scale-110" className="absolute inset-0 opacity-30 blur-3xl scale-110"
style={{ style={{
backgroundImage: `url(${playlist.cover_url})`, backgroundImage: `url(${encodeURI(safeUrl)})`,
backgroundSize: 'cover', backgroundSize: 'cover',
backgroundPosition: 'center', backgroundPosition: 'center',
}} }}

View file

@ -1,15 +1,18 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useTranslation } from '@/hooks/useTranslation';
export function PlaylistDetailPageNotFound() { export function PlaylistDetailPageNotFound() {
const { t } = useTranslation();
return ( return (
<div className="container mx-auto px-4 py-8 flex flex-col items-center justify-center min-h-layout-page text-center"> <div className="container mx-auto px-4 py-8 flex flex-col items-center justify-center min-h-layout-page text-center">
<div className="text-9xl mb-4">👾</div> <div className="text-9xl mb-4">👾</div>
<h2 className="text-3xl font-heading font-bold text-destructive mb-2"> <h2 className="text-3xl font-heading font-bold text-destructive mb-2">
Playlist Not Found {t('playlists.shared.notFound')}
</h2> </h2>
<Button variant="outline" className="mt-8" asChild> <Button variant="outline" className="mt-8" asChild>
<Link to="/features/library">Back to Library</Link> <Link to="/library">{t('playlists.shared.backToLibrary')}</Link>
</Button> </Button>
</div> </div>
); );

View file

@ -2,6 +2,7 @@ import { Plus, Users, Sparkles, Search } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card'; import { Card } from '@/components/ui/card';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { useTranslation } from '@/hooks/useTranslation';
import { PlaylistTrackList } from '../../components/PlaylistTrackList'; import { PlaylistTrackList } from '../../components/PlaylistTrackList';
import { AddTrackToPlaylistModal } from '../../components/AddTrackToPlaylistModal'; import { AddTrackToPlaylistModal } from '../../components/AddTrackToPlaylistModal';
import { CollaboratorList } from '../../components/CollaboratorList'; import { CollaboratorList } from '../../components/CollaboratorList';
@ -54,6 +55,7 @@ export function PlaylistDetailPageTabs({
onTrackAdded, onTrackAdded,
onCollaboratorAdded, onCollaboratorAdded,
}: PlaylistDetailPageTabsProps) { }: PlaylistDetailPageTabsProps) {
const { t } = useTranslation();
return ( return (
<> <>
<Tabs defaultValue="tracks" className="w-full"> <Tabs defaultValue="tracks" className="w-full">
@ -62,21 +64,21 @@ export function PlaylistDetailPageTabs({
value="tracks" value="tracks"
className="rounded-none border-b-2 border-transparent data-[state=active]:bg-transparent data-[state=active]:border-primary data-[state=active]:text-primary py-4 px-0 text-lg font-heading bg-transparent" className="rounded-none border-b-2 border-transparent data-[state=active]:bg-transparent data-[state=active]:border-primary data-[state=active]:text-primary py-4 px-0 text-lg font-heading bg-transparent"
> >
Tracks {t('playlists.tracks')}
</TabsTrigger> </TabsTrigger>
{permissions.canRead && ( {permissions.canRead && (
<TabsTrigger <TabsTrigger
value="collaborators" value="collaborators"
className="rounded-none border-b-2 border-transparent data-[state=active]:bg-transparent data-[state=active]:border-primary data-[state=active]:text-primary py-4 px-0 text-lg font-heading bg-transparent" className="rounded-none border-b-2 border-transparent data-[state=active]:bg-transparent data-[state=active]:border-primary data-[state=active]:text-primary py-4 px-0 text-lg font-heading bg-transparent"
> >
Collaborators {t('playlists.collaborators')}
</TabsTrigger> </TabsTrigger>
)} )}
<TabsTrigger <TabsTrigger
value="recommendations" value="recommendations"
className="rounded-none border-b-2 border-transparent data-[state=active]:bg-transparent data-[state=active]:border-primary data-[state=active]:text-primary py-4 px-0 text-lg font-heading bg-transparent" className="rounded-none border-b-2 border-transparent data-[state=active]:bg-transparent data-[state=active]:border-primary data-[state=active]:text-primary py-4 px-0 text-lg font-heading bg-transparent"
> >
Recommendations {t('playlists.detail.recommendations')}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
@ -86,7 +88,8 @@ export function PlaylistDetailPageTabs({
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input <input
placeholder="Filter tracks..." placeholder={t('playlists.detail.filterTracks')}
aria-label={t('playlists.detail.filterTracks')}
className="bg-transparent border-none text-sm text-foreground placeholder:text-muted-foreground focus:outline-none pl-9 py-2 w-64" className="bg-transparent border-none text-sm text-foreground placeholder:text-muted-foreground focus:outline-none pl-9 py-2 w-64"
/> />
</div> </div>
@ -97,7 +100,7 @@ export function PlaylistDetailPageTabs({
variant="ghost" variant="ghost"
className="text-primary hover:text-primary hover:bg-primary/10" className="text-primary hover:text-primary hover:bg-primary/10"
> >
<Plus className="w-4 h-4 mr-2" /> Add Tracks <Plus className="w-4 h-4 mr-2" /> {t('playlists.detail.addTracks')}
</Button> </Button>
)} )}
</div> </div>
@ -121,11 +124,11 @@ export function PlaylistDetailPageTabs({
<Card variant="glass" className="p-6"> <Card variant="glass" className="p-6">
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-bold flex items-center gap-2"> <h3 className="text-xl font-bold flex items-center gap-2">
<Users className="w-5 h-5 text-primary" /> Squad Members <Users className="w-5 h-5 text-primary" /> {t('playlists.detail.squadMembers')}
</h3> </h3>
{permissions.canManageCollaborators && ( {permissions.canManageCollaborators && (
<Button onClick={() => setIsAddCollaboratorModalOpen(true)}> <Button onClick={() => setIsAddCollaboratorModalOpen(true)}>
<Plus className="w-4 h-4 mr-2" /> Invite <Plus className="w-4 h-4 mr-2" /> {t('playlists.detail.invite')}
</Button> </Button>
)} )}
</div> </div>
@ -141,7 +144,7 @@ export function PlaylistDetailPageTabs({
<div className="bg-gradient-to-br from-primary/10 to-transparent p-6 rounded-2xl border border-primary/20"> <div className="bg-gradient-to-br from-primary/10 to-transparent p-6 rounded-2xl border border-primary/20">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<Sparkles className="w-5 h-5 text-yellow-400 animate-pulse" /> <Sparkles className="w-5 h-5 text-yellow-400 animate-pulse" />
<h3 className="text-xl font-bold">Suggested for you</h3> <h3 className="text-xl font-bold">{t('playlists.detail.suggestedForYou')}</h3>
</div> </div>
<PlaylistRecommendations <PlaylistRecommendations
limit={8} limit={8}

View file

@ -4,10 +4,12 @@ import { usePlaylist } from '../../hooks/usePlaylist';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { playlistsApi } from '@/services/api/playlists'; import { playlistsApi } from '@/services/api/playlists';
import { usePlaylistPermissions } from '../../hooks/usePlaylistPermissions'; import { usePlaylistPermissions } from '../../hooks/usePlaylistPermissions';
import { useTranslation } from '@/hooks/useTranslation';
import toast from '@/utils/toast'; import toast from '@/utils/toast';
import type { Track } from '../../types'; import type { Track } from '../../types';
export function usePlaylistDetailPage(playlistIdOverride?: string) { export function usePlaylistDetailPage(playlistIdOverride?: string) {
const { t } = useTranslation();
const { id: paramId } = useParams<{ id: string }>(); const { id: paramId } = useParams<{ id: string }>();
const id = playlistIdOverride ?? paramId ?? ''; const id = playlistIdOverride ?? paramId ?? '';
@ -30,15 +32,15 @@ export function usePlaylistDetailPage(playlistIdOverride?: string) {
const handleTrackAdded = () => { const handleTrackAdded = () => {
setIsAddTrackModalOpen(false); setIsAddTrackModalOpen(false);
refetch(); refetch();
toast.success('Track added'); toast.success(t('playlists.detail.trackAdded'));
}; };
const handleTrackRemoved = () => { const handleTrackRemoved = () => {
refetch(); refetch();
toast.success('Track removed'); toast.success(t('playlists.detail.trackRemoved'));
}; };
const handleTracksReordered = () => { const handleTracksReordered = () => {
refetch(); refetch();
toast.success('Reordered'); toast.success(t('playlists.detail.reordered'));
}; };
const openShareModal = () => setIsShareModalOpen(true); const openShareModal = () => setIsShareModalOpen(true);
const openAddCollaboratorModal = () => setIsAddCollaboratorModalOpen(true); const openAddCollaboratorModal = () => setIsAddCollaboratorModalOpen(true);

View file

@ -1,8 +1,13 @@
import { Routes, Route, Navigate } from 'react-router-dom'; import { Routes, Route, Navigate, useParams } from 'react-router-dom';
import { PlaylistListPage } from './pages/PlaylistListPage'; import { PlaylistListPage } from './pages/PlaylistListPage';
import { PlaylistDetailPage } from './pages/PlaylistDetailPage'; import { PlaylistDetailPage } from './pages/PlaylistDetailPage';
import { FavorisRedirectPage } from './pages/FavorisRedirectPage'; import { FavorisRedirectPage } from './pages/FavorisRedirectPage';
function PlaylistEditRedirect() {
const { id } = useParams<{ id: string }>();
return <Navigate to={`/playlists/${id}`} replace />;
}
export function PlaylistRoutes() { export function PlaylistRoutes() {
return ( return (
<Routes> <Routes>
@ -10,10 +15,7 @@ export function PlaylistRoutes() {
<Route path="/favoris" element={<FavorisRedirectPage />} /> <Route path="/favoris" element={<FavorisRedirectPage />} />
<Route path="/new" element={<Navigate to="/playlists" replace />} /> <Route path="/new" element={<Navigate to="/playlists" replace />} />
<Route path="/:id" element={<PlaylistDetailPage />} /> <Route path="/:id" element={<PlaylistDetailPage />} />
<Route <Route path="/:id/edit" element={<PlaylistEditRedirect />} />
path="/:id/edit"
element={<Navigate to="/playlists/:id" replace />}
/>
<Route path="*" element={<Navigate to="/playlists" replace />} /> <Route path="*" element={<Navigate to="/playlists" replace />} />
</Routes> </Routes>
); );

View file

@ -87,10 +87,15 @@ export async function createPlaylist(
*/ */
export async function getPlaylist(id: string): Promise<Playlist> { export async function getPlaylist(id: string): Promise<Playlist> {
return wrapPlaylistError(async () => { return wrapPlaylistError(async () => {
const response = await apiClient.get<{ playlist: Playlist }>( const response = await apiClient.get<Playlist>(`/playlists/${id}`);
`/playlists/${id}`, // After interceptor unwrapping, response.data is the inner data object.
); // Backend returns playlist flat in data (not nested under data.playlist).
return response.data.playlist; const raw = response.data as Record<string, unknown>;
// Handle both shapes: { id, title, ... } (flat) and { playlist: { ... } } (wrapped)
if (raw.playlist && typeof raw.playlist === 'object') {
return raw.playlist as Playlist;
}
return response.data as Playlist;
}); });
} }
@ -100,10 +105,11 @@ export async function getPlaylist(id: string): Promise<Playlist> {
*/ */
export async function getPlaylistByShareToken(token: string): Promise<Playlist> { export async function getPlaylistByShareToken(token: string): Promise<Playlist> {
return wrapPlaylistError(async () => { return wrapPlaylistError(async () => {
const response = await apiClient.get<{ playlist: Playlist }>( const response = await apiClient.get<Playlist>(
`/playlists/shared/${token}`, `/playlists/shared/${token}`,
); );
return response.data.playlist; // Backend returns playlist directly in data (not nested under data.playlist)
return response.data;
}); });
} }

Some files were not shown because too many files have changed in this diff Show more