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
// import storybook from "eslint-plugin-storybook";
@ -293,4 +296,4 @@ export default [js.configs.recommended, {
'*.config.cjs',
'**/ui.backup/**',
],
}];
}, ...storybook.configs["flat/recommended"]];

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,7 @@
import { Link } from 'react-router-dom';
import { Checkbox } from '@/components/ui/checkbox';
import { AlertCircle, Loader2, Check, X } from 'lucide-react';
import { useTranslation } from '@/hooks/useTranslation';
import { AuthInput } from '../AuthInput';
import { AuthButton } from '../AuthButton';
import { PasswordStrengthIndicator } from '../PasswordStrengthIndicator';
@ -36,11 +37,13 @@ export function RegisterPageForm({
onFieldBlur,
onSubmit,
}: RegisterPageFormProps) {
const { t } = useTranslation();
return (
<form
onSubmit={onSubmit}
className="space-y-4"
aria-label="Formulaire d'inscription"
aria-label={t('auth.register.formAriaLabel')}
data-testid="register-form"
>
{error && (
@ -60,7 +63,7 @@ export function RegisterPageForm({
<AuthInput
id="register-username"
type="text"
label="Nom d'utilisateur"
label={t('auth.register.username')}
value={formData.username}
onChange={(e) => onFieldChange('username', e.target.value)}
onBlur={() => onFieldBlur('username')}
@ -73,17 +76,17 @@ export function RegisterPageForm({
{checkingUsername ? (
<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>Vérification...</span>
<span>{t('auth.register.usernameCheck.checking')}</span>
</p>
) : usernameAvailable === true ? (
<p className="text-xs text-success flex items-center gap-1.5" role="status">
<Check className="h-3 w-3" />
<span>Ce nom d'utilisateur est disponible</span>
<span>{t('auth.register.usernameCheck.available')}</span>
</p>
) : usernameAvailable === false ? (
<p className="text-xs text-destructive flex items-center gap-1.5" role="alert">
<X className="h-3 w-3" />
<span>Ce nom d'utilisateur est déjà pris</span>
<span>{t('auth.register.usernameCheck.unavailable')}</span>
</p>
) : null}
</div>
@ -94,7 +97,7 @@ export function RegisterPageForm({
<AuthInput
id="register-email"
type="email"
label="Email"
label={t('auth.register.email')}
value={formData.email}
onChange={(e) => onFieldChange('email', e.target.value)}
onBlur={() => onFieldBlur('email')}
@ -108,7 +111,7 @@ export function RegisterPageForm({
<AuthInput
id="register-password"
type="password"
label="Mot de passe"
label={t('auth.register.password')}
value={formData.password}
onChange={(e) => onFieldChange('password', e.target.value)}
onBlur={() => onFieldBlur('password')}
@ -124,7 +127,7 @@ export function RegisterPageForm({
<AuthInput
id="register-password_confirm"
type="password"
label="Confirmer le mot de passe"
label={t('auth.register.confirmPassword')}
value={formData.password_confirm}
onChange={(e) => onFieldChange('password_confirm', e.target.value)}
onBlur={() => onFieldBlur('password_confirm')}
@ -147,30 +150,30 @@ export function RegisterPageForm({
}}
required
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>
<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
to="/terms"
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>
<span>et la</span>
<span>{t('auth.register.terms.and')}</span>
<Link
to="/privacy"
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>
</label>
</div>
<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>
{errors.terms && (
<p id="terms-error" className="text-sm text-destructive animate-shake" role="alert">
@ -187,10 +190,10 @@ export function RegisterPageForm({
{loading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Inscription en cours...
{t('auth.register.loadingText')}
</>
) : (
"S'inscrire"
t('auth.register.registerButton')
)}
</AuthButton>
</form>

View file

@ -1,10 +1,12 @@
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 {
email: string;
resendLoading: boolean;
resendSuccess: boolean;
resendError: string | null;
onResend: () => void;
}
@ -12,8 +14,11 @@ export function RegisterPageVerificationNotice({
email,
resendLoading,
resendSuccess,
resendError,
onResend,
}: RegisterPageVerificationNoticeProps) {
const { t } = useTranslation();
return (
<div className="text-center space-y-5 animate-fade-in py-4" role="status" aria-live="polite">
{/* Success icon */}
@ -24,15 +29,15 @@ export function RegisterPageVerificationNotice({
</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">
Un email de vérification a é envoyé à{' '}
{t('auth.register.verification.emailSent')}{' '}
<span className="font-medium text-foreground">{email}</span>
</p>
</div>
<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>
{resendSuccess && (
@ -42,7 +47,18 @@ export function RegisterPageVerificationNotice({
aria-live="polite"
>
<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>
)}
@ -51,15 +67,15 @@ export function RegisterPageVerificationNotice({
variant="secondary"
onClick={onResend}
disabled={resendLoading}
aria-label="Renvoyer l'email de vérification"
aria-label={t('auth.register.verification.resendButton')}
>
{resendLoading ? (
<>
<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>
</div>

View file

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

View file

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

View file

@ -1,6 +1,5 @@
import { useMutation } from '@tanstack/react-query';
import { useAuthStore } from '../store/authStore';
import { register as registerService } from '@/services/api/auth';
import type { RegisterRequest } from '@/services/api/auth';
export const useRegister = () => {
@ -8,12 +7,7 @@ export const useRegister = () => {
return useMutation({
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);
return response;
},
});
};

View file

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

View file

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

View file

@ -7,6 +7,55 @@ import { LoginPage } from './LoginPage';
import { useAuthStore } from '@/features/auth/store/authStore';
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
vi.mock('@/features/auth/store/authStore', () => ({
useAuthStore: vi.fn(),
@ -77,14 +126,14 @@ describe('LoginPage', () => {
it('should render login form', () => {
render(<LoginPage />, { wrapper });
expect(screen.getByText('Welcome Back')).toBeInTheDocument();
expect(screen.getByText('Login')).toBeInTheDocument();
expect(
screen.getByText('Sign in to your account'),
screen.getByText('Sign in to your Veza account'),
).toBeInTheDocument();
expect(screen.getByLabelText('Email')).toBeInTheDocument();
expect(screen.getByLabelText('Password')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Sign In' }),
screen.getByRole('button', { name: 'Sign in' }),
).toBeInTheDocument();
});
@ -125,7 +174,7 @@ describe('LoginPage', () => {
render(<LoginPage />, { wrapper });
const form = screen
.getByRole('button', { name: 'Sign In' })
.getByRole('button', { name: 'Sign in' })
.closest('form');
expect(form).toBeInTheDocument();
@ -154,7 +203,7 @@ describe('LoginPage', () => {
});
const form = screen
.getByRole('button', { name: 'Sign In' })
.getByRole('button', { name: 'Sign in' })
.closest('form');
await act(async () => {
if (form) {
@ -175,7 +224,7 @@ describe('LoginPage', () => {
const emailInput = screen.getByLabelText('Email');
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 user.type(emailInput, 'test@example.com');
@ -224,7 +273,7 @@ describe('LoginPage', () => {
await act(async () => {
await user.type(screen.getByLabelText('Email'), 'test@example.com');
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(() => {
@ -257,7 +306,7 @@ describe('LoginPage', () => {
render(<LoginPage />, { wrapper });
expect(
screen.getByText('Incorrect email or password'),
screen.getByText('Invalid email or password'),
).toBeInTheDocument();
});
@ -287,9 +336,7 @@ describe('LoginPage', () => {
render(<LoginPage />, { wrapper });
expect(
screen.getByText(
"Your email is not verified. Check your inbox.",
),
screen.getByText('Email not verified'),
).toBeInTheDocument();
});
@ -345,7 +392,7 @@ describe('LoginPage', () => {
render(<LoginPage />, { wrapper });
expect(
screen.getByText('Incorrect email or password'),
screen.getByText('Invalid email or password'),
).toBeInTheDocument();
});
@ -357,12 +404,10 @@ describe('LoginPage', () => {
render(<LoginPage />, { wrapper });
// When loading, the button text changes to "Chargement..."
expect(screen.getByText('Chargement...')).toBeInTheDocument();
const loadingButton = screen.getByRole('button', {
name: 'Chargement en cours',
});
// When loading, the button is disabled and has aria-busy
const loadingButton = screen.getByRole('button', { name: 'Sign in' });
expect(loadingButton).toBeDisabled();
expect(loadingButton).toHaveAttribute('aria-busy', 'true');
});
it('should update form data on input change', async () => {
@ -452,34 +497,28 @@ describe('LoginPage', () => {
await waitFor(
() => {
expect(screen.getByText('Format email invalide')).toBeInTheDocument();
expect(screen.getByText('Invalid email format')).toBeInTheDocument();
},
{ 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();
render(<LoginPage />, { wrapper });
const passwordInput = screen.getByLabelText('Password');
// Type short password
// Type short password — should NOT show length error on login
await act(async () => {
await user.type(passwordInput, '12345');
await user.tab(); // Trigger blur
});
await waitFor(
() => {
expect(
screen.getByText(
'Le mot de passe doit contenir au moins 6 caractères',
),
).toBeInTheDocument();
},
{ timeout: 1000 },
);
// No password length validation error should appear
await new Promise((resolve) => setTimeout(resolve, 200));
expect(screen.queryByText(/at least/i)).not.toBeInTheDocument();
expect(screen.queryByText(/au moins/i)).not.toBeInTheDocument();
});
it('should clear error when user starts typing', async () => {
@ -498,7 +537,7 @@ describe('LoginPage', () => {
// Wait for validation error
await waitFor(
() => {
const errorText = screen.queryByText('Email requis');
const errorText = screen.queryByText('Email is required');
if (errorText) {
expect(errorText).toBeInTheDocument();
}
@ -513,7 +552,7 @@ describe('LoginPage', () => {
// Error should be cleared when typing
// 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
expect(emailInput).toHaveValue('t');
});
@ -532,7 +571,7 @@ describe('LoginPage', () => {
await waitFor(
() => {
expect(screen.getByText('Format email invalide')).toBeInTheDocument();
expect(screen.getByText('Invalid email format')).toBeInTheDocument();
},
{ 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();
render(<LoginPage />, { wrapper });
const passwordInput = screen.getByLabelText('Password');
// Test short password
// Empty password should show required error
await act(async () => {
await user.type(passwordInput, '12345');
await user.click(passwordInput);
await user.tab();
});
await waitFor(
() => {
expect(
screen.getByText(
'Le mot de passe doit contenir au moins 6 caractères',
),
).toBeInTheDocument();
expect(screen.getByText('Password is required')).toBeInTheDocument();
},
{ timeout: 1000 },
);
// Test valid password length
// Any non-empty password should pass (no length restriction on login)
await act(async () => {
await user.clear(passwordInput);
await user.type(passwordInput, 'password123');
await user.type(passwordInput, 'a');
await user.tab();
});
await waitFor(
() => {
expect(
screen.queryByText(
'Le mot de passe doit contenir au moins 6 caractères',
),
).not.toBeInTheDocument();
expect(screen.queryByText('Password is required')).not.toBeInTheDocument();
},
{ timeout: 1000 },
);
@ -642,7 +672,7 @@ describe('LoginPage', () => {
const emailInput = screen.getByLabelText('Email');
const passwordInput = screen.getByLabelText('Password');
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 user.type(emailInput, 'test@example.com');
@ -689,7 +719,7 @@ describe('LoginPage', () => {
const checkbox = screen.getByLabelText(
'Remember me',
) as HTMLInputElement;
const submitButton = screen.getByRole('button', { name: 'Sign In' });
const submitButton = screen.getByRole('button', { name: 'Sign in' });
// Ensure checkbox is unchecked
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 { useQuery, useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '../store/authStore';
@ -15,18 +15,19 @@ import type { ApiError } from '@/schemas/apiSchemas';
import { Checkbox } from '@/components/ui/checkbox';
import { AlertCircle } from 'lucide-react';
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 (typeof error === 'object' && error !== null && 'message' in error && 'code' in error) {
return formatApiErrorMessage(error as ApiError);
}
if (error instanceof Error) {
const msg = error.message?.toLowerCase() ?? '';
if (msg.includes('invalid credentials') || msg.includes('401')) return 'Incorrect email or password';
if (msg.includes('email not verified')) return "Your email is not verified. Check your inbox.";
if (msg.includes('network')) return 'Connection error. Check your internet.';
return error.message || 'An error occurred. Please try again.';
if (msg.includes('invalid credentials') || msg.includes('401')) return t('auth.login.errors.invalidCredentials');
if (msg.includes('email not verified')) return t('auth.login.errors.emailNotVerified');
if (msg.includes('network')) return t('auth.login.errors.connectionError');
return error.message || t('auth.login.errors.genericError');
}
return String(error);
}
@ -34,10 +35,17 @@ function getLoginErrorMessage(error: unknown): string {
type Pending2FA = { email: string; password: string; remember_me: boolean };
export function LoginPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { isAuthenticated, isLoading, complete2FALogin, error: authStoreError } = useAuthStore();
const { mutate: handleLogin, isPending: loading, error } = useLogin();
const { isAuthenticated, isLoading, complete2FALogin } = useAuthStore();
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>({
email: '',
password: '',
@ -50,14 +58,11 @@ export function LoginPage() {
const [pending2FA, setPending2FA] = useState<Pending2FA | null>(null);
const [loading2FA, setLoading2FA] = useState(false);
const [error2FA, setError2FA] = useState<string | null>(null);
const [loginError, setLoginError] = useState<string | null>(() => {
const stored = sessionStorage.getItem('login_error');
if (stored) {
sessionStorage.removeItem('login_error');
return stored;
}
return null;
});
// Keep formDataRef in sync
useEffect(() => {
formDataRef.current = formData;
}, [formData]);
// Charger l'email sauvegardé au montage
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) {
return <Navigate to="/dashboard" replace />;
}
@ -79,13 +91,11 @@ export function LoginPage() {
): string | undefined => {
switch (field) {
case 'email':
if (!value) return 'Email requis';
if (!/\S+@\S+\.\S+/.test(value)) return 'Format email invalide';
if (!value) return t('auth.login.errors.emailRequired');
if (!/\S+@\S+\.\S+/.test(value)) return t('auth.login.errors.emailInvalid');
return undefined;
case 'password':
if (!value) return 'Mot de passe requis';
if (value.length < 6)
return 'Le mot de passe doit contenir au moins 6 caractères';
if (!value) return t('auth.login.errors.passwordRequired');
return undefined;
default:
return undefined;
@ -111,8 +121,7 @@ export function LoginPage() {
const handleChange = (field: keyof LoginFormData, value: string) => {
setFormData({ ...formData, [field]: value });
if (loginError) setLoginError(null);
if (authStoreError) useAuthStore.getState().clearError?.();
if (displayError) setDisplayError(null);
if (errors[field]) {
setErrors({ ...errors, [field]: undefined });
}
@ -126,7 +135,7 @@ export function LoginPage() {
} else {
localStorage.removeItem('rememberedEmail');
}
setLoginError(null);
setDisplayError(null);
handleLogin(
{ ...formData, remember_me },
{
@ -144,9 +153,7 @@ export function LoginPage() {
navigate('/dashboard', { replace: true });
},
onError: (err) => {
const msg = getLoginErrorMessage(err);
setLoginError(msg);
sessionStorage.setItem('login_error', msg);
setDisplayError(getLoginErrorMessage(err, t));
logger.error('Login error', {
error: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined,
@ -185,7 +192,7 @@ export function LoginPage() {
setPending2FA(null);
navigate('/dashboard', { replace: true });
} catch (err) {
setError2FA(getLoginErrorMessage(err));
setError2FA(getLoginErrorMessage(err, t));
logger.error('2FA login error', {
error: err instanceof Error ? err.message : String(err),
});
@ -197,9 +204,9 @@ export function LoginPage() {
if (show2FA && pending2FA) {
return (
<AuthLayout
title="Two-factor authentication"
subtitle="Enter the code from your authenticator app"
footerLinks={[{ label: 'Back to sign in', to: '/login' }]}
title={t('auth.twoFactor.title')}
subtitle={t('auth.twoFactor.subtitle')}
footerLinks={[{ label: t('auth.twoFactor.backToSignIn'), to: '/login' }]}
>
<div className="space-y-6">
{error2FA && (
@ -227,9 +234,9 @@ export function LoginPage() {
return (
<AuthLayout
title="Welcome Back"
subtitle="Sign in to your account"
footerLinks={[{ label: "Don't have an account? Sign up", to: '/register' }]}
title={t('auth.login.title')}
subtitle={t('auth.login.subtitle')}
footerLinks={[{ label: t('auth.login.footerLink'), to: '/register' }]}
>
<div className="space-y-6">
{/* OAuth providers + divider (hidden when no providers) */}
@ -255,27 +262,27 @@ export function LoginPage() {
<div className="w-full section-divider" />
</div>
<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>
</>
)}
<form onSubmit={onSubmit} className="space-y-4" data-testid="login-form">
{(loginError || error) && (
{displayError && (
<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"
role="alert"
>
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<p>{loginError || getLoginErrorMessage(error)}</p>
<p>{displayError}</p>
</div>
)}
<div className="space-y-4">
<AuthInput
type="email"
label="Email"
label={t('auth.login.email')}
value={formData.email}
autoComplete="email"
onChange={(e) => handleChange('email', e.target.value)}
@ -285,7 +292,7 @@ export function LoginPage() {
/>
<AuthInput
type="password"
label="Password"
label={t('auth.login.password')}
value={formData.password}
autoComplete="current-password"
onChange={(e) => handleChange('password', e.target.value)}
@ -302,13 +309,13 @@ export function LoginPage() {
id="remember_me"
checked={remember_me}
onCheckedChange={(checked) => setRemember_me(checked)}
label="Remember me"
label={t('auth.login.rememberMe')}
/>
<Link
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"
>
Forgot password?
{t('auth.login.forgotPassword')}
</Link>
</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)]"
data-testid="login-submit"
>
Sign In
{t('auth.login.loginButton')}
</AuthButton>
</form>
</div>

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react';
import { within, userEvent } from '@storybook/test';
import { within, userEvent } from 'storybook/test';
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 { Loader2 } from 'lucide-react';
import { AuthLayout } from '../components/AuthLayout';
import { AuthInput } from '../components/AuthInput';
import { AuthButton } from '../components/AuthButton';
import { AuthErrorMessage } from '../components/AuthErrorMessage';
import { PasswordStrengthIndicator } from '../components/PasswordStrengthIndicator';
import { usePasswordReset } from '../hooks/usePasswordReset';
import { useTranslation } from '@/hooks/useTranslation';
import { resetPasswordSchema } from '@/schemas/validation';
import type { ResetPasswordFormData } from '../types';
export function ResetPasswordPage() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { handleReset, loading, error, success } = usePasswordReset();
const [token, setToken] = useState<string | null>(null);
const [countdown, setCountdown] = useState(3);
const [formData, setFormData] = useState<ResetPasswordFormData>({
token: '',
password: '',
@ -22,7 +26,11 @@ export function ResetPasswordPage() {
Partial<Record<keyof ResetPasswordFormData, string>>
>({});
// Extraire le token depuis l'URL
useEffect(() => {
document.title = t('auth.resetPassword.pageTitle');
}, [t]);
// Extract token from URL
useEffect(() => {
const tokenParam = searchParams.get('token');
if (tokenParam) {
@ -31,40 +39,52 @@ export function ResetPasswordPage() {
}
}, [searchParams]);
// Rediriger vers login après succès
// Redirect to login after success with countdown
useEffect(() => {
if (success) {
const timer = setTimeout(() => {
navigate('/login', { replace: true });
}, 3000);
return () => clearTimeout(timer);
setCountdown(3);
const interval = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(interval);
navigate('/login', { replace: true });
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
}
return undefined;
}, [success, navigate]);
const validate = (): boolean => {
const newErrors: Partial<Record<keyof ResetPasswordFormData, string>> = {};
if (!formData.password) {
newErrors.password = 'Mot de passe requis';
} else if (formData.password.length < 8) {
newErrors.password =
'Le mot de passe doit contenir au moins 8 caractères';
const validate = useCallback((): boolean => {
const result = resetPasswordSchema.safeParse(formData);
if (!result.success) {
const newErrors: Partial<Record<keyof ResetPasswordFormData, string>> = {};
for (const issue of result.error.issues) {
const field = issue.path[0] as keyof ResetPasswordFormData;
if (!newErrors[field]) {
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;
}
if (!formData.confirmPassword) {
newErrors.confirmPassword = 'Confirmation du mot de passe requise';
} else if (formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = 'Les mots de passe ne correspondent pas';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
setErrors({});
return true;
}, [formData, t]);
const handleChange = (field: keyof ResetPasswordFormData, value: string) => {
setFormData({ ...formData, [field]: value });
// Clear error for this field when user starts typing
if (errors[field]) {
setErrors({ ...errors, [field]: undefined });
}
@ -72,48 +92,48 @@ export function ResetPasswordPage() {
const handleBlur = (field: keyof ResetPasswordFormData) => {
const value = formData[field];
let error: string | undefined;
let fieldError: string | undefined;
if (field === 'password') {
if (!value) {
error = 'Mot de passe requis';
} else if (value.length < 8) {
error = 'Le mot de passe doit contenir au moins 8 caractères';
fieldError = t('auth.resetPassword.errors.passwordRequired');
} else {
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') {
if (!value) {
error = 'Confirmation du mot de passe requise';
fieldError = t('auth.resetPassword.errors.confirmRequired');
} else if (formData.password !== value) {
error = 'Les mots de passe ne correspondent pas';
fieldError = t('auth.resetPassword.errors.passwordMismatch');
}
}
if (error) {
setErrors({ ...errors, [field]: error });
} else {
setErrors({ ...errors, [field]: undefined });
}
setErrors((prev) => ({ ...prev, [field]: fieldError }));
};
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!token) {
return;
}
if (!token) return;
if (validate()) {
await handleReset(formData);
}
};
// Afficher un message si le token est invalide ou manquant
// State: invalid/missing token
if (!token) {
return (
<AuthLayout
title="Lien de réinitialisation invalide"
subtitle="Le lien de réinitialisation est invalide ou a expiré"
title={t('auth.resetPassword.invalidToken.title')}
subtitle={t('auth.resetPassword.invalidToken.subtitle')}
footerLinks={[
{ label: 'Demander un nouveau lien', to: '/forgot-password' },
{ label: 'Retour à la connexion', to: '/login' },
{ label: t('auth.resetPassword.requestNewLink'), to: '/forgot-password' },
{ label: t('auth.resetPassword.backToLogin'), to: '/login' },
]}
>
<div
@ -122,10 +142,9 @@ export function ResetPasswordPage() {
aria-live="assertive"
>
<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">
Le lien de réinitialisation est invalide ou a expiré. Veuillez
demander un nouveau lien.
{t('auth.resetPassword.invalidToken.body')}
</p>
</div>
</div>
@ -133,52 +152,74 @@ export function ResetPasswordPage() {
);
}
// Afficher le message de succès
// State: success
if (success) {
return (
<AuthLayout
title="Mot de passe réinitialisé"
subtitle="Votre mot de passe a été modifié avec succès"
footerLinks={[{ label: 'Retour à la connexion', to: '/login' }]}
title={t('auth.resetPassword.success.title')}
subtitle={t('auth.resetPassword.success.subtitle')}
footerLinks={[{ label: t('auth.resetPassword.backToLogin'), to: '/login' }]}
>
<div className="text-center space-y-4" role="status" aria-live="polite">
<div
className="bg-success/10 border border-success text-success px-4 py-4 rounded"
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">
Votre mot de passe a é réinitialisé avec succès. Vous allez être
redirigé vers la page de connexion...
{t('auth.resetPassword.success.body')}
</p>
</div>
<p className="text-sm text-muted-foreground">
{t('auth.resetPassword.success.redirecting', { seconds: countdown })}
</p>
</div>
</AuthLayout>
);
}
// State: form
return (
<AuthLayout
title="Réinitialiser le mot de passe"
subtitle="Entrez votre nouveau mot de passe"
footerLinks={[{ label: 'Retour à la connexion', to: '/login' }]}
title={t('auth.resetPassword.title')}
subtitle={t('auth.resetPassword.subtitle')}
footerLinks={[{ label: t('auth.resetPassword.backToLogin'), to: '/login' }]}
>
<form
onSubmit={onSubmit}
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
type="password"
label="Nouveau mot de passe"
label={t('auth.resetPassword.password')}
value={formData.password}
onChange={(e) => handleChange('password', e.target.value)}
onBlur={() => handleBlur('password')}
error={errors.password}
required
autoComplete="new-password"
showPasswordToggle
/>
<div aria-live="polite" aria-atomic="true">
<PasswordStrengthIndicator password={formData.password} />
@ -186,17 +227,25 @@ export function ResetPasswordPage() {
<AuthInput
type="password"
label="Confirmer le mot de passe"
label={t('auth.resetPassword.confirmPassword')}
value={formData.confirmPassword}
onChange={(e) => handleChange('confirmPassword', e.target.value)}
onBlur={() => handleBlur('confirmPassword')}
error={errors.confirmPassword}
required
autoComplete="new-password"
showPasswordToggle
/>
<AuthButton type="submit" loading={loading}>
Réinitialiser le mot de passe
<AuthButton type="submit" loading={loading} data-testid="reset-password-submit">
{loading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin inline" />
{t('auth.resetPassword.submitButton')}
</>
) : (
t('auth.resetPassword.submitButton')
)}
</AuthButton>
</form>
</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();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
@ -37,6 +85,15 @@ describe('VerifyEmailPage', () => {
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 () => {
vi.mocked(authApi.verifyEmail).mockResolvedValue({
message: 'Email verified',
@ -50,20 +107,8 @@ describe('VerifyEmailPage', () => {
render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
// Should show verifying state initially
await waitFor(() => {
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();
const heading = document.querySelector('h1');
expect(heading).toBeInTheDocument();
});
it('should extract token from URL and verify email', async () => {
@ -79,7 +124,6 @@ describe('VerifyEmailPage', () => {
render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
// Wait for verification to complete
await waitFor(
() => {
expect(authApi.verifyEmail).toHaveBeenCalledWith({ token: 'abc123' });
@ -101,17 +145,16 @@ describe('VerifyEmailPage', () => {
render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
// Wait for success message
await waitFor(
() => {
expect(screen.getByText('Email vérifié')).toBeInTheDocument();
expect(screen.getByTestId('verify-email-success')).toBeInTheDocument();
},
{ timeout: 2000 },
);
});
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);
const wrapperWithToken = ({ children }: { children: React.ReactNode }) => (
@ -122,12 +165,12 @@ describe('VerifyEmailPage', () => {
render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
// Wait for error message
await waitFor(
() => {
expect(
screen.getByText('Token invalide ou expiré'),
).toBeInTheDocument();
expect(screen.getByTestId('verify-email-error')).toBeInTheDocument();
expect(screen.getByTestId('verify-email-message')).toHaveTextContent(
'Token invalid or expired',
);
},
{ timeout: 3000 },
);
@ -146,12 +189,10 @@ describe('VerifyEmailPage', () => {
render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
// Wait for success
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(
() => {
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' };
vi.mocked(authApi.verifyEmail).mockRejectedValue(error);
@ -172,10 +213,9 @@ describe('VerifyEmailPage', () => {
render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
// Wait for error state
await waitFor(
() => {
expect(screen.getByText('Réessayer')).toBeInTheDocument();
expect(screen.getByTestId('verify-email-retry')).toBeInTheDocument();
},
{ timeout: 3000 },
);
@ -196,24 +236,21 @@ describe('VerifyEmailPage', () => {
render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
// Wait for error state
await waitFor(() => {
expect(screen.getByText('Réessayer')).toBeInTheDocument();
expect(screen.getByTestId('verify-email-retry')).toBeInTheDocument();
});
// Click retry button
const retryButton = screen.getByText('Réessayer');
const retryButton = screen.getByTestId('verify-email-retry');
await act(async () => {
await user.click(retryButton);
});
// Should call verifyEmail again
await waitFor(() => {
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' };
vi.mocked(authApi.verifyEmail).mockRejectedValue(error);
@ -225,12 +262,9 @@ describe('VerifyEmailPage', () => {
render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
// Wait for error state
await waitFor(
() => {
expect(
screen.getByText(/Renvoyer l'email de vérification/),
).toBeInTheDocument();
expect(screen.getByTestId('verify-email-resend')).toBeInTheDocument();
},
{ timeout: 3000 },
);
@ -254,23 +288,18 @@ describe('VerifyEmailPage', () => {
render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
// Wait for error state
await waitFor(
() => {
expect(
screen.getByText(/Renvoyer l'email de vérification/),
).toBeInTheDocument();
expect(screen.getByTestId('verify-email-resend')).toBeInTheDocument();
},
{ timeout: 3000 },
);
// Click resend button
const resendButton = screen.getByText(/Renvoyer l'email de vérification/);
const resendButton = screen.getByTestId('verify-email-resend');
await act(async () => {
await user.click(resendButton);
});
// Should call resendVerification
await waitFor(
() => {
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 error = { message: 'Verification failed' };
vi.mocked(authApi.verifyEmail).mockRejectedValue(error);
@ -299,39 +328,27 @@ describe('VerifyEmailPage', () => {
render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
// Wait for error state
await waitFor(
() => {
expect(
screen.getByText(/Renvoyer l'email de vérification/),
).toBeInTheDocument();
},
{ timeout: 2000 },
);
await waitFor(() => {
expect(screen.getByTestId('verify-email-resend')).toBeInTheDocument();
});
// Click resend button
const resendButton = screen.getByText(/Renvoyer l'email de vérification/);
const resendButton = screen.getByTestId('verify-email-resend');
await act(async () => {
await user.click(resendButton);
});
// Verify that resendVerification was called
await waitFor(
() => {
expect(authApi.resendVerification).toHaveBeenCalledWith({
email: 'test@example.com',
});
},
{ timeout: 2000 },
);
await waitFor(() => {
expect(
screen.getByTestId('verify-email-resend-success'),
).toBeInTheDocument();
});
});
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 error = { message: 'Verification failed' };
vi.mocked(authApi.verifyEmail).mockRejectedValue(error);
// No email in localStorage
localStorage.clear();
const wrapperWithToken = ({ children }: { children: React.ReactNode }) => (
@ -342,32 +359,57 @@ describe('VerifyEmailPage', () => {
render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
// Wait for error state
await waitFor(
() => {
expect(
screen.getByText(/Renvoyer l'email de vérification/),
).toBeInTheDocument();
},
{ timeout: 2000 },
);
await waitFor(() => {
expect(screen.getByTestId('verify-email-resend')).toBeInTheDocument();
});
// Click resend button
const resendButton = screen.getByText(/Renvoyer l'email de vérification/);
const resendButton = screen.getByTestId('verify-email-resend');
await act(async () => {
await user.click(resendButton);
});
// resendVerification should NOT be called since email is missing
await new Promise((resolve) => setTimeout(resolve, 200));
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 });
const loginLink = screen.getByText('Retour à la connexion');
const loginLink = screen.getByText('Back to login');
expect(loginLink).toBeInTheDocument();
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 { AuthButton } from '../components/AuthButton';
import { authApi } from '@/services/api/auth';
import { useTranslation } from '@/hooks/useTranslation';
import type { ApiError } from '@/schemas/apiSchemas';
export function VerifyEmailPage() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [status, setStatus] = useState<'verifying' | 'success' | 'error'>(
'verifying',
);
const [message, setMessage] = useState(
'Vérification de votre email en cours...',
);
const [message, setMessage] = useState('');
const [loading, setLoading] = useState(false);
const [resendLoading, setResendLoading] = useState(false);
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 hasVerifiedRef = useRef(false);
// Extraire le token depuis l'URL
// Extract token from URL and verify automatically (guarded against Strict Mode double-invocation)
useEffect(() => {
const tokenParam = searchParams.get('token');
if (tokenParam) {
if (tokenParam && !hasVerifiedRef.current) {
hasVerifiedRef.current = true;
setToken(tokenParam);
// Vérifier l'email automatiquement si le token est présent
handleVerifyEmail(tokenParam);
} else {
// Clean token from URL to prevent leakage via Referer header / browser history
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');
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(() => {
return () => {
if (cooldownIntervalRef.current) {
clearInterval(cooldownIntervalRef.current);
}
};
}, []);
// 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);
}
};
if (resendCooldown <= 0) return;
const timer = setTimeout(
() => setResendCooldown((c) => c - 1),
1000,
);
return () => clearTimeout(timer);
}, [resendCooldown]);
// Rediriger vers login après succès
// Redirect to login after successful verification
useEffect(() => {
if (status === 'success') {
const timer = setTimeout(() => {
@ -81,102 +79,126 @@ export function VerifyEmailPage() {
return undefined;
}, [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 {
setLoading(true);
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');
setMessage('Votre email a été vérifié avec succès !');
setMessage(t('auth.verifyEmail.message.success'));
localStorage.removeItem('pendingVerificationEmail');
} catch (error) {
setStatus('error');
const apiError = error as ApiError;
setMessage(apiError.message || 'La vérification a échoué');
setMessage(
apiError.message || t('auth.verifyEmail.message.defaultError'),
);
} finally {
setLoading(false);
}
};
const handleResendVerificationEmail = async () => {
if (resendCooldown > 0 || resendLoading) {
return;
}
if (resendCooldown > 0 || resendLoading) return;
try {
setResendLoading(true);
setResendSuccess(false);
// Récupérer l'email depuis localStorage (stocké lors de l'inscription)
const email = localStorage.getItem('pendingVerificationEmail');
if (!email) {
setMessage(
'Email non trouvé. Veuillez vous réinscrire ou contacter le support.',
);
setMessage(t('auth.verifyEmail.message.emailNotFound'));
return;
}
await authApi.resendVerification({ email });
// Définir cooldown de 60 secondes
setResendCooldown(60);
// Afficher message de confirmation
setMessage(
'Email de vérification envoyé ! Veuillez vérifier votre boîte mail.',
);
setResendSuccess(true);
setMessage(t('auth.verifyEmail.message.resendSuccess'));
} catch (error) {
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 {
setResendLoading(false);
}
};
// Afficher le statut de vérification
const footerLinks = [
{ label: t('auth.verifyEmail.footer.backToLogin'), to: '/login' },
];
// Verifying state
if (status === 'verifying') {
return (
<AuthLayout
title="Vérification de l'email"
subtitle="Vérification en cours..."
footerLinks={[{ label: 'Retour à la connexion', to: '/login' }]}
title={t('auth.verifyEmail.title.verifying')}
subtitle={t('auth.verifyEmail.subtitle.verifying')}
footerLinks={footerLinks}
>
<div
className="text-center space-y-4"
role="status"
aria-live="polite"
aria-busy="true"
data-testid="verify-email-spinner"
>
<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>
<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">
Vérification de votre email en cours, veuillez patienter
{t('auth.verifyEmail.srOnly.verifying')}
</span>
</div>
</AuthLayout>
);
}
// Afficher le message de succès
// Success state
if (status === 'success') {
return (
<AuthLayout
title="Email vérifié"
subtitle="Votre email a été vérifié avec succès"
footerLinks={[{ label: 'Retour à la connexion', to: '/login' }]}
title={t('auth.verifyEmail.title.success')}
subtitle={t('auth.verifyEmail.subtitle.success')}
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
className="bg-success/10 border border-success text-success px-4 py-4 rounded"
role="alert"
>
<p className="font-medium">Succès !</p>
<p className="text-sm mt-1">{message}</p>
<p className="font-medium">{t('auth.verifyEmail.success.title')}</p>
<p className="text-sm mt-1" data-testid="verify-email-message">
{message}
</p>
<p className="text-xs mt-2 text-muted-foreground">
Vous allez être redirigé vers la page de connexion...
{t('auth.verifyEmail.success.redirecting')}
</p>
</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 (
<AuthLayout
title="Vérification de l'email"
subtitle="Une erreur s'est produite"
footerLinks={[{ label: 'Retour à la connexion', to: '/login' }]}
title={t('auth.verifyEmail.title.error')}
subtitle={t('auth.verifyEmail.subtitle.error')}
footerLinks={footerLinks}
>
<div className="text-center space-y-4">
<div
className="bg-destructive/10 border border-destructive text-destructive px-4 py-4 rounded"
role="alert"
aria-live="assertive"
>
<p className="font-medium">Erreur</p>
<p className="text-sm mt-1">{message}</p>
</div>
<div className="text-center space-y-4" data-testid="verify-email-error">
{resendSuccess ? (
<div
className="bg-success/10 border border-success text-success px-4 py-4 rounded"
role="status"
aria-live="polite"
data-testid="verify-email-resend-success"
>
<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">
{token && (
<AuthButton
onClick={() => handleVerifyEmail(token)}
onClick={handleRetryVerification}
loading={loading}
type="button"
data-testid="verify-email-retry"
>
Réessayer
{t('auth.verifyEmail.button.retry')}
</AuthButton>
)}
@ -218,21 +257,30 @@ export function VerifyEmailPage() {
disabled={resendCooldown > 0}
type="button"
variant="secondary"
data-testid="verify-email-resend"
aria-label={
resendCooldown > 0
? `Renvoyer l'email de vérification dans ${resendCooldown} secondes`
: "Renvoyer l'email de vérification"
? t('auth.verifyEmail.button.resendCooldownAriaLabel', {
seconds: String(resendCooldown),
})
: t('auth.verifyEmail.button.resendAriaLabel')
}
>
{resendCooldown > 0 ? (
<>
<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 aria-hidden="true">Renvoyer dans {resendCooldown}s</span>
</>
) : (
"Renvoyer l'email de vérification"
t('auth.verifyEmail.button.resend')
)}
</AuthButton>
</div>

View file

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

View file

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

View file

@ -1,14 +1,38 @@
import { useTranslation } from '@/hooks/useTranslation';
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() {
const { t } = useTranslation();
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' },
];
const { recentActivity, isLoading } = useDashboard();
return (
<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">
<h2 className="font-heading text-lg tracking-tight" style={{ fontWeight: 300 }}>{t('dashboard.recentActivity')}</h2>
<Link
to="/library"
to="/notifications"
className="text-[10px] text-muted-foreground/40 hover:text-foreground transition-colors tracking-[0.15em] uppercase font-heading"
style={{ fontWeight: 300 }}
>
@ -29,21 +53,39 @@ export function RecentActivityCard() {
{t('dashboard.recentActivityDescription')}
</p>
<div className="space-y-0">
{activities.map((activity, i) => (
<div key={i} 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 ${activity.dotColor} shrink-0`} />
<div className="flex-1 min-w-0">
<p className="text-sm text-foreground/80 truncate font-heading" style={{ fontWeight: 300 }}>
{activity.params ? t(activity.textKey, activity.params) : t(activity.textKey)}
</p>
{isLoading ? (
<div className="space-y-0">
{[...Array(3)].map((_, i) => (
<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="w-1.5 h-1.5 rounded-full bg-muted/30 shrink-0" />
<div className="flex-1 min-w-0">
<div className="h-3.5 bg-muted/20 rounded-sm w-3/4" />
</div>
<div className="h-3 bg-muted/20 rounded-sm w-12" />
</div>
<span className="text-[10px] text-muted-foreground/30 shrink-0 font-heading" style={{ fontWeight: 300 }}>
{t(activity.timeKey)}
</span>
</div>
))}
</div>
))}
</div>
) : recentActivity.length === 0 ? (
<p className="text-sm text-muted-foreground/40 text-center py-10 font-heading" style={{ fontWeight: 300 }}>
{t('dashboard.recentActivityDescription')}
</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>
);
}

View file

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

View file

@ -70,8 +70,8 @@ function WelcomeBanner({ username }: { username: string }) {
const QUICK_ACTIONS = [
{ 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: Search, labelKey: 'dashboard.discoverMusic', path: '/search', color: 'bg-warning/10 text-warning' },
{ icon: ListMusic, labelKey: 'dashboard.createPlaylist', path: '/playlists', color: 'bg-success/10 text-success' },
{ 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' },
] as const;

View file

@ -1,4 +1,5 @@
import { apiClient } from '@/services/api/client';
import { isCancel } from 'axios';
import { logger } from '@/utils/logger';
// BE-PAGE-001: Dashboard service for fetching dashboard data
@ -102,6 +103,11 @@ export async function getDashboardData(
library_preview: dashboardData.library_preview,
};
} 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', {
error: error instanceof Error ? error.message : String(error),
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 { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { usePlayerStore } from '@/features/player/store/playerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { ContentFadeIn } from '@/components/ui/ContentFadeIn';
import { TrackGrid } from '@/features/tracks/components/TrackGrid';
import { TrackCardSkeleton } from '@/features/tracks/components/TrackCardSkeleton';
@ -44,6 +45,7 @@ export function DiscoverPage() {
const play = usePlayerStore((s) => s.play);
const navigate = useNavigate();
const loadMoreRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation();
const browseGenre = genreFromQuery;
const browseTag = tagFromQuery;
@ -151,7 +153,7 @@ export function DiscoverPage() {
<ContentFadeIn className="min-h-layout-page pb-36">
<div className="flex items-center gap-3">
<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 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) => (
@ -166,6 +168,8 @@ export function DiscoverPage() {
);
}
const hasEditorialPlaylists = editorialData?.items && editorialData.items.length > 0;
return (
<ContentFadeIn className="min-h-layout-page pb-36">
<div className="space-y-8">
@ -177,9 +181,10 @@ export function DiscoverPage() {
size="sm"
onClick={goBack}
className="-ml-2 hover:bg-[var(--sumi-bg-hover)]"
aria-label={t('discover.back')}
>
<ChevronLeft className="w-5 h-5" />
Back
{t('discover.back')}
</Button>
) : null}
<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
: browseTag
? browseTag
: 'Discover'}
: t('discover.title')}
</h1>
{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>
{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">
By Genre
{t('discover.byGenre')}
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
{genres.map((g, i) => (
<button
key={g.slug}
onClick={() => handleGenreClick(g)}
aria-label={t('discover.browseGenre', { genre: g.name })}
className={cn(
'relative overflow-hidden rounded-2xl min-h-[7.5rem] p-4 pb-5 text-left',
'bg-gradient-to-br',
@ -224,7 +230,7 @@ export function DiscoverPage() {
</span>
{'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">
{(g as Genre & { count?: number }).count} tracks
{t('discover.trackCount', { count: (g as Genre & { count?: number }).count })}
</span>
)}
{/* Decorative circles */}
@ -239,10 +245,10 @@ export function DiscoverPage() {
) : null}
{showGenreList ? (
<section className="space-y-4">
<section className="space-y-4" aria-label={t('discover.editorialPlaylists')}>
<div className="section-divider my-2" />
<h2 className="text-lg font-heading font-semibold tracking-tight">
Editorial Playlists
{t('discover.editorialPlaylists')}
</h2>
{editorialLoading ? (
<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} />
))}
</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">
{editorialData.items.map((pl) => (
<PlaylistCard
@ -269,7 +275,11 @@ export function DiscoverPage() {
/>
))}
</div>
) : null}
) : (
<p className="text-sm text-muted-foreground py-4">
{t('discover.noEditorialPlaylists')}
</p>
)}
</section>
) : null}
@ -287,7 +297,7 @@ export function DiscoverPage() {
<>
<TrackGrid
tracks={tracks}
emptyMessage="No tracks in this genre"
emptyMessage={t('discover.noTracksInGenre')}
onTrackPlay={handlePlay}
onTrackClick={handleTrackClick}
gap="md"

View file

@ -1,5 +1,5 @@
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 ServerErrorPage from './ServerErrorPage';

View file

@ -8,9 +8,11 @@ import { Link } from 'react-router-dom';
import { Avatar } from '@/components/ui/avatar';
import { getSuggestions } from '@/features/profile/services/profileService';
import { FollowButton } from '@/features/profile/components/FollowButton';
import { useTranslation } from '@/hooks/useTranslation';
import { UserPlus, Loader2, ChevronRight } from 'lucide-react';
export function SuggestionsWidget() {
const { t } = useTranslation();
const { data, isLoading } = useQuery({
queryKey: ['suggestions'],
queryFn: () => getSuggestions(5),
@ -23,7 +25,7 @@ export function SuggestionsWidget() {
<div className="p-4 pb-3">
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
<UserPlus className="w-4 h-4 text-primary" />
Suggested Accounts
{t('feed.suggestedAccounts')}
</h3>
</div>
<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">
<p className="text-sm font-medium text-foreground truncate">@{user.username}</p>
<p className="text-xs text-muted-foreground/80">
{(user.followers_count ?? 0).toLocaleString()} followers
{t('feed.followers', { count: user.followers_count ?? 0 })}
</p>
</div>
</Link>
@ -79,7 +81,7 @@ export function SuggestionsWidget() {
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"
>
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)]" />
</Link>
</div>

View file

@ -14,7 +14,7 @@ import { ErrorDisplay } from '@/components/ui/ErrorDisplay';
import { SuggestionsWidget } from '../components/SuggestionsWidget';
import { feedService } from '@/services/feedService';
import { Music2, Loader2, Disc3, Sparkles } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useTranslation } from '@/hooks/useTranslation';
export function FeedPage() {
const { t } = useTranslation();
@ -155,7 +155,7 @@ export function FeedPage() {
</h2>
<TrackGrid
tracks={tracks}
emptyMessage="No new tracks"
emptyMessage={t('feed.noNewTracks')}
onTrackPlay={handlePlay}
onTrackClick={handleTrackClick}
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 { motion, useInView } from 'framer-motion';
import {
@ -14,6 +14,7 @@ import {
Globe,
Heart,
} from 'lucide-react';
import { useTranslation } from '@/hooks/useTranslation';
/*
TALAS LANDING PAGE Pre-launch
@ -57,13 +58,20 @@ function Section({ children, className = '', id }: { children: React.ReactNode;
}
export default function LandingPage() {
const { t } = useTranslation();
const [email, setEmail] = useState('');
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
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) => {
e.preventDefault();
if (!email || !email.includes('@')) return;
// BUG-13 fix: Better email validation
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return;
setStatus('loading');
try {
const res = await fetch('/api/v1/newsletter/subscribe', {
@ -73,19 +81,71 @@ export default function LandingPage() {
});
if (!res.ok) {
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');
setEmail('');
} catch (err) {
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);
}
};
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 (
<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 ═══ */}
<div className="fixed inset-0 pointer-events-none" aria-hidden="true">
<div
@ -112,9 +172,14 @@ export default function LandingPage() {
</div>
{/* ═══ 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">
<div className="max-w-[1200px] mx-auto px-6 h-14 flex items-center justify-between">
<div className="flex items-center gap-3">
{/* BUG-10 fix: Added aria-label. BUG-15 fix: Added flex-wrap and overflow handling for mobile */}
<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"
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)' }}>
<span className="text-[var(--sumi-bg-base)] font-[var(--sumi-font-heading)] text-sm font-semibold relative z-10">T</span>
</div>
@ -125,34 +190,35 @@ export default function LandingPage() {
TALAS
</span>
</div>
<div className="flex items-center gap-6">
<div className="flex items-center gap-3 sm:gap-6 overflow-hidden">
<a
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 }}
>
PRODUIT
{t('landing.nav.product')}
</a>
<a
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 }}
>
PLATEFORME
{t('landing.nav.platform')}
</a>
<Link
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 }}
data-testid="launch-login-link"
>
CONNEXION
{t('landing.nav.login')}
</Link>
</div>
</div>
</nav>
{/* ═══ 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
className="max-w-[800px] mx-auto text-center"
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"
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
>
Matériel audio professionnel ouvert, réparable, transparent.
{t('landing.hero.tagline1')}
<br />
Plateforme musicale éthique sans tracking, sans algorithme.
{t('landing.hero.tagline2')}
</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"
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
>
Lancement bientôt Rejoins les premiers
{t('landing.hero.cta')}
</motion.p>
{/* Email capture — hero */}
@ -208,12 +274,13 @@ export default function LandingPage() {
custom={4}
onSubmit={handleSubscribe}
className="flex flex-col sm:flex-row items-center gap-3 max-w-[460px] mx-auto"
data-testid="launch-hero-form"
>
{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} />
<span style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}>
Inscription confirmée. À bientôt.
{t('landing.form.successHero')}
</span>
</div>
) : (
@ -222,23 +289,29 @@ export default function LandingPage() {
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="ton@email.com"
placeholder={t('landing.hero.placeholder')}
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"
style={{ fontFamily: 'var(--sumi-font-body)' }}
/>
<button
type="submit"
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"
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 400 }}
>
{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} />
</>
)}
@ -248,7 +321,7 @@ export default function LandingPage() {
</motion.form>
{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 */}
@ -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" />
<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>
</motion.div>
</motion.div>
</header>
{/* ═══ 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">
<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 }}>
{t('landing.values.kicker')}
</span>
<h2
className="text-3xl sm:text-4xl mt-3 tracking-[0.05em]"
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 200 }}
>
Trois engagements
{t('landing.values.title')}
</h2>
<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>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{[
{
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) => (
{valueCards.map((card, i) => (
<motion.div
key={card.title}
variants={inkReveal}
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"
>
{/* Ghost kanji in card */}
<span
className="absolute -right-2 -top-4 text-[100px] opacity-[0.025] pointer-events-none select-none"
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 200 }}
aria-hidden="true"
>
{card.kanji}
</span>
@ -346,7 +397,7 @@ export default function LandingPage() {
</Section>
{/* ═══ 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="grid grid-cols-1 md:grid-cols-2 gap-16 items-center">
{/* Microphone illustration — abstract */}
@ -356,7 +407,6 @@ export default function LandingPage() {
className="relative flex items-center justify-center"
>
<div className="relative w-[280px] h-[380px]">
{/* Ink wash backdrop */}
<div
className="absolute inset-0 rounded-sm"
style={{
@ -367,16 +417,13 @@ export default function LandingPage() {
border: '1px solid var(--sumi-border-faint)',
}}
/>
{/* Microphone silhouette */}
<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" />
<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>
{/* 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-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">
<span className="text-[var(--sumi-accent)] text-xs" style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 500 }}>T</span>
</div>
@ -391,7 +438,7 @@ export default function LandingPage() {
className="text-[10px] tracking-[0.3em] text-[var(--sumi-kin)] uppercase"
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
>
Premier produit
{t('landing.product.kicker')}
</motion.span>
<motion.h2
@ -400,29 +447,23 @@ export default function LandingPage() {
className="text-3xl sm:text-4xl mt-3 mb-2 tracking-[0.03em]"
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 200 }}
>
Microphone Condensateur
{t('landing.product.title')}
</motion.h2>
<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
variants={inkReveal}
custom={2}
className="text-sm text-[var(--sumi-text-secondary)] leading-relaxed mb-8"
style={{ fontFamily: 'var(--sumi-font-body)' }}
>
Large diaphragme. Préampli OPA1642. Corps aluminium usiné.
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.
{t('landing.product.desc')}
</motion.p>
<motion.ul variants={inkReveal} custom={3} className="space-y-3 mb-10">
{[
{ 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) => (
{productFeatures.map((item) => (
<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} />
<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"
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 400 }}
>
ÊTRE NOTIFIÉ DU LANCEMENT
{t('landing.product.cta')}
<ArrowRight size={13} />
</a>
</motion.div>
@ -446,7 +487,7 @@ export default function LandingPage() {
</Section>
{/* ═══ 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">
<motion.span
variants={inkReveal}
@ -454,7 +495,7 @@ export default function LandingPage() {
className="text-[10px] tracking-[0.3em] text-[var(--sumi-kin)] uppercase"
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
>
La plateforme
{t('landing.platform.kicker')}
</motion.span>
<motion.h2
@ -474,18 +515,11 @@ export default function LandingPage() {
className="text-xs tracking-[0.2em] text-[var(--sumi-text-tertiary)] mb-12"
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
>
STREAMING DU MICRO À L'AUDITEUR
{t('landing.platform.subtitle')}
</motion.p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-6 max-w-[700px] mx-auto mb-16">
{[
{ 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) => (
{platformFeatures.map((feat, i) => (
<motion.div
key={feat.label}
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"
style={{ fontFamily: 'var(--sumi-font-body)' }}
>
435 000 lignes de code. Audit de sécurité externe. 34 suites de tests.
Backend Go + Stream server Rust + Frontend React.
Auto-hébergé. Pas de cloud. Pas de VC.
{t('landing.platform.stats')}
</motion.p>
</div>
</Section>
{/* ═══ 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">
<motion.span
variants={inkReveal}
@ -534,7 +566,7 @@ export default function LandingPage() {
className="text-2xl sm:text-3xl mb-3 tracking-[0.05em]"
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 200 }}
>
Rejoins les premiers
{t('landing.notify.title')}
</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" />
@ -545,8 +577,7 @@ export default function LandingPage() {
className="text-sm text-[var(--sumi-text-secondary)] mb-10 leading-relaxed"
style={{ fontFamily: 'var(--sumi-font-body)' }}
>
Inscris-toi pour être notifié du lancement.
Pas de spam un seul email le jour J.
{t('landing.notify.desc')}
</motion.p>
<motion.form
@ -554,12 +585,14 @@ export default function LandingPage() {
custom={2}
onSubmit={handleSubscribe}
className="flex flex-col sm:flex-row items-center gap-3 max-w-[460px] mx-auto"
data-testid="launch-notify-form"
>
{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} />
<span style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}>
C'est noté. Merci.
{t('landing.form.successCta')}
</span>
</div>
) : (
@ -568,23 +601,29 @@ export default function LandingPage() {
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="ton@email.com"
placeholder={t('landing.notify.placeholder')}
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"
style={{ fontFamily: 'var(--sumi-font-body)' }}
/>
<button
type="submit"
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"
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 400 }}
>
{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} />
</>
)}
@ -594,13 +633,13 @@ export default function LandingPage() {
</motion.form>
{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>
</Section>
{/* ═══ 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="flex flex-col md:flex-row items-center justify-between gap-8">
{/* Logo */}
@ -616,16 +655,13 @@ export default function LandingPage() {
</span>
</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">
{[
{ label: 'Open Source', href: 'https://github.com/talas-audio' },
{ label: 'Confidentialité', href: '/privacy' },
{ label: 'Contact', href: 'mailto:contact@talas.audio' },
].map((link) => (
{footerLinks.map((link) => (
<a
key={link.label}
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]"
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]"
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>
</div>
</div>
</footer>
</div>
</main>
);
}

View file

@ -2,9 +2,17 @@
* 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';
export function QueuePage() {
const { t } = useTranslation();
useEffect(() => {
document.title = t('queue.pageTitle');
}, [t]);
return <QueueView />;
}

View file

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

View file

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

View file

@ -5,6 +5,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Play, Download, Trash2, MoreVertical } from 'lucide-react';
import { useTranslation } from '@/hooks/useTranslation';
import type { Track } from '@/types';
import { getArtistName, formatDuration, safeFormatDistanceToNow } from './utils';
@ -14,16 +15,18 @@ interface LibraryPageListProps {
}
export function LibraryPageList({ tracks, onPlayTrack }: LibraryPageListProps) {
const { t } = useTranslation();
return (
<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">
<tr>
<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 hidden md:table-cell">Artist</th>
<th className="px-6 py-4 hidden sm:table-cell">Date</th>
<th className="px-6 py-4 text-right">Duration</th>
<th className="px-6 py-4">{t('library.table.title')}</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">{t('library.table.date')}</th>
<th className="px-6 py-4 text-right">{t('library.table.duration')}</th>
<th className="px-6 py-4 w-12" />
</tr>
</thead>
@ -57,6 +60,7 @@ export function LibraryPageList({ tracks, onPlayTrack }: LibraryPageListProps) {
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"
onClick={(e) => e.stopPropagation()}
aria-label={t('library.table.moreOptions', { title: track.title })}
>
<MoreVertical className="w-4 h-4" />
</button>
@ -66,10 +70,10 @@ export function LibraryPageList({ tracks, onPlayTrack }: LibraryPageListProps) {
className="bg-background/90 backdrop-blur-xl border-border"
>
<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 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>
</DropdownMenuContent>
</DropdownMenu>

View file

@ -2,6 +2,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Search, Grid, List as ListIcon, Plus } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/hooks/useTranslation';
import type { LibraryPageViewMode } from './types';
interface LibraryPageToolbarProps {
@ -19,14 +20,16 @@ export function LibraryPageToolbar({
onSearchChange,
onNewClick,
}: LibraryPageToolbarProps) {
const { t } = useTranslation();
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="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">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search..."
placeholder={t('library.searchPlaceholder')}
value={searchQuery}
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"
@ -42,7 +45,7 @@ export function LibraryPageToolbar({
'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'
)}
aria-label="Grid view"
aria-label={t('library.viewMode.grid')}
>
<Grid className="w-4 h-4" />
</button>
@ -53,7 +56,7 @@ export function LibraryPageToolbar({
'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'
)}
aria-label="List view"
aria-label={t('library.viewMode.list')}
>
<ListIcon className="w-4 h-4" />
</button>
@ -62,7 +65,7 @@ export function LibraryPageToolbar({
onClick={onNewClick}
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>
</div>
</div>

View file

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

View file

@ -41,6 +41,8 @@ export function PlayerControls({
<Tooltip content="Shuffle">
<button
onClick={onShuffle}
aria-label={shuffle ? "Disable shuffle" : "Enable shuffle"}
aria-pressed={shuffle}
className={cn(
iconBtnClass,
size,
@ -55,6 +57,7 @@ export function PlayerControls({
<button
data-testid="prev-button"
aria-label="Previous track"
onClick={onPrevious}
className={cn(iconBtnClass, size, "text-foreground hover:text-primary hover:bg-white/5")}
>
@ -63,6 +66,7 @@ export function PlayerControls({
<button
data-testid="play-button"
aria-label={isPlaying ? "Pause" : "Play"}
onClick={onPlayPause}
className={cn(
"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
data-testid="next-button"
aria-label="Next track"
onClick={onNext}
className={cn(iconBtnClass, size, "text-foreground hover:text-primary hover:bg-white/5")}
>
@ -87,6 +92,8 @@ export function PlayerControls({
<Tooltip content="Repeat">
<button
onClick={onRepeat}
aria-label={repeat === 'off' ? 'Enable repeat' : repeat === 'track' ? 'Repeat track' : 'Repeat playlist'}
aria-pressed={repeat !== 'off'}
className={cn(
iconBtnClass,
size,

View file

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

View file

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

View file

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

View file

@ -198,6 +198,9 @@ export function usePlayer(
// Callback pour les erreurs (Invalid URI, réseau, etc.)
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);
// Try fallback to direct audio download URL before giving up

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,6 +6,7 @@
import { useState } from 'react';
import { Play, Pause, Music, GripVertical } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/hooks/useTranslation';
import { RemoveTrackButton } from './RemoveTrackButton';
import { AddToFavorisButton } from './AddToFavorisButton';
import type { PlaylistTrack, Track } from '../types';
@ -52,6 +53,7 @@ export function PlaylistTrackItem({
canRemoveTracks = true,
isFavorisPlaylist = false,
}: PlaylistTrackItemProps) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
const handleClick = () => {
@ -77,14 +79,14 @@ export function PlaylistTrackItem({
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
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é) */}
{dragHandleProps && (
<div
{...dragHandleProps}
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" />
</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"
aria-label={
isPlaying
? `Mettre en pause ${track.title}`
: `Lire ${track.title}`
? t('playlists.detail.pauseTrack', { title: track.title })
: t('playlists.detail.playTrack', { title: track.title })
}
>
{isPlaying ? (
@ -110,7 +112,7 @@ export function PlaylistTrackItem({
)}
</button>
) : (
<span>{position}</span>
<span>{position + 1}</span>
)}
</div>
@ -119,7 +121,7 @@ export function PlaylistTrackItem({
{track.cover_art_path ? (
<img
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"
/>
) : (
@ -150,7 +152,7 @@ export function PlaylistTrackItem({
{/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0">
{!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 && (
<RemoveTrackButton

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,6 @@
import { Library } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/hooks/useTranslation';
import type { PlaylistListEmptyVariant } from './types';
interface PlaylistListEmptyProps {
@ -8,6 +9,8 @@ interface PlaylistListEmptyProps {
}
export function PlaylistListEmpty({ variant, className }: PlaylistListEmptyProps) {
const { t } = useTranslation();
if (variant === 'no_playlists') {
return (
<div
@ -16,9 +19,9 @@ export function PlaylistListEmpty({ variant, className }: PlaylistListEmptyProps
aria-live="polite"
>
<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">
Start by creating your first playlist to organize your tracks.
{t('playlists.emptyDescription')}
</p>
</div>
);
@ -31,10 +34,9 @@ export function PlaylistListEmpty({ variant, className }: PlaylistListEmptyProps
aria-live="polite"
>
<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">
No playlists match your search criteria. Try adjusting your filters or
search terms.
{t('search.noResultsHint')}
</p>
</div>
);

View file

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

View file

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

View file

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

View file

@ -15,6 +15,7 @@ import {
} from '@dnd-kit/sortable';
import { useReorderPlaylistTracks } from '../../hooks/usePlaylist';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/hooks/useTranslation';
import { sortTracksByPosition } from './utils';
import type { PlaylistTrack, Track } from '../../types';
@ -37,6 +38,7 @@ export function usePlaylistTrackList(
[tracks],
);
const { t } = useTranslation();
const { toast, error: showError } = useToast();
const reorderMutation = useReorderPlaylistTracks();
@ -67,11 +69,11 @@ export function usePlaylistTrackList(
playlistId: String(playlistId),
trackIds,
});
toast({ message: 'Playlist réorganisée', type: 'success' });
toast({ message: t('playlists.detail.playlistReordered'), type: 'success' });
onTracksReordered?.();
} catch {
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,
showError,
onTracksReordered,
t,
],
);

View file

@ -11,7 +11,7 @@ import type {
CreatePlaylistRequest,
UpdatePlaylistRequest,
} from '../types';
import { TokenStorage } from '@/services/tokenStorage';
import { useAuthStore } from '@/features/auth/store/authStore';
import { useUser } from '@/features/auth/hooks/useUser';
// Create Playlist
@ -183,13 +183,14 @@ export function usePlaylists(
sortBy?: 'created_at' | 'title' | 'track_count',
sortOrder?: 'asc' | 'desc',
) {
// Check if token exists in storage (more reliable than waiting for store hydration)
const hasToken = !!TokenStorage.getAccessToken();
// With httpOnly cookies, TokenStorage.getAccessToken() is always null.
// Use auth store's isAuthenticated flag instead.
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const page = Math.floor(offset / limit) + 1;
return useQuery({
queryKey: ['playlists', limit, offset, 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
*/
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { PlaylistList } from '../components/PlaylistList';
import { CreatePlaylistDialog } from '../components/CreatePlaylistDialog';
import { ImportPlaylistButton } from '../components/ImportPlaylistButton';
@ -22,6 +22,7 @@ import {
X,
ArrowUpDown,
} from 'lucide-react';
import { useTranslation } from '@/hooks/useTranslation';
import '../styles/playlists.mobile.css';
// FE-PAGE-009: Complete Playlist List page implementation
@ -30,11 +31,16 @@ type SortField = 'created_at' | 'title' | 'track_count';
type SortOrder = 'asc' | 'desc';
export function PlaylistListPage() {
const { t } = useTranslation();
const [enableSelection, setEnableSelection] = useState(false);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [showFilters, setShowFilters] = useState(false);
useEffect(() => {
document.title = t('playlists.pageTitle');
}, [t]);
// FE-PAGE-009: Filtering state
const [filterIsPublic, setFilterIsPublic] = useState<boolean | 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>
<h1 className="text-heading-1 font-heading text-foreground">
Playlists
{t('playlists.title')}
</h1>
<p className="text-sm sm:text-base text-muted-foreground">
Découvrez et gérez vos playlists
{t('playlists.subtitle')}
</p>
</div>
<div className="flex gap-2">
@ -75,12 +81,12 @@ export function PlaylistListPage() {
size="sm"
onClick={() => setIsCreateDialogOpen(true)}
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"
>
<Plus className="h-4 w-4 sm:mr-2" aria-hidden="true" />
<span className="hidden sm:inline">Créer</span>
<span className="sm:hidden">Nouvelle</span>
<span className="hidden sm:inline">{t('playlists.createButton')}</span>
<span className="sm:hidden">{t('playlists.createButtonMobile')}</span>
</Button>
<ImportPlaylistButton className="touch-manipulation min-h-11 sm:min-h-0" />
<Button
@ -90,13 +96,13 @@ export function PlaylistListPage() {
className="touch-manipulation min-h-11 sm:min-h-0"
aria-label={
enableSelection
? 'Désactiver la sélection'
: 'Activer la sélection'
? t('playlists.disableSelection')
: t('playlists.enableSelection')
}
>
<CheckSquare className="h-4 w-4 sm:mr-2" aria-hidden="true" />
<span className="hidden sm:inline">
{enableSelection ? 'Annuler' : 'Sélectionner'}
{enableSelection ? t('playlists.deselectButton') : t('playlists.selectButton')}
</span>
</Button>
</div>
@ -113,7 +119,8 @@ export function PlaylistListPage() {
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Rechercher des playlists..."
placeholder={t('playlists.searchPlaceholder')}
aria-label={t('playlists.searchPlaceholder')}
className="pl-10"
data-testid="playlist-search"
/>
@ -123,17 +130,17 @@ export function PlaylistListPage() {
onClick={() => setShowFilters(!showFilters)}
>
<Filter className="mr-2 h-4 w-4" />
Filters
{t('playlists.filtersButton')}
{hasActiveFilters && (
<span className="ml-2 bg-primary text-foreground rounded-full px-2 py-0.5 text-xs">
Active
{t('playlists.filtersActive')}
</span>
)}
</Button>
{hasActiveFilters && (
<Button variant="ghost" onClick={handleClearFilters}>
<X className="mr-2 h-4 w-4" />
Clear
{t('playlists.clearFilters')}
</Button>
)}
</div>
@ -141,7 +148,7 @@ export function PlaylistListPage() {
{showFilters && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-4 border-t">
<div className="space-y-2">
<label className="text-sm font-medium">Visibility</label>
<label className="text-sm font-medium">{t('playlists.filterVisibility')}</label>
<Select
value={
filterIsPublic === undefined
@ -156,14 +163,14 @@ export function PlaylistListPage() {
else setFilterIsPublic(false);
}}
options={[
{ value: 'all', label: 'All' },
{ value: 'public', label: 'Public' },
{ value: 'private', label: 'Private' },
{ value: 'all', label: t('playlists.all') },
{ value: 'public', label: t('playlists.public') },
{ value: 'private', label: t('playlists.private') },
]}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Owner</label>
<label className="text-sm font-medium">{t('playlists.filterOwner')}</label>
<Select
value={filterOwner}
onChange={(value) =>
@ -175,14 +182,14 @@ export function PlaylistListPage() {
)
}
options={[
{ value: 'all', label: 'All' },
{ value: 'mine', label: 'My Playlists' },
{ value: 'others', label: 'Others' },
{ value: 'all', label: t('playlists.all') },
{ value: 'mine', label: t('playlists.myPlaylists') },
{ value: 'others', label: t('playlists.others') },
]}
/>
</div>
<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">
<Select
value={sortBy}
@ -194,9 +201,9 @@ export function PlaylistListPage() {
)
}
options={[
{ value: 'created_at', label: 'Date' },
{ value: 'title', label: 'Title' },
{ value: 'track_count', label: 'Tracks' },
{ value: 'created_at', label: t('playlists.sortByDate') },
{ value: 'title', label: t('playlists.sortByTitle') },
{ value: 'track_count', label: t('playlists.sortByTracks') },
]}
className="flex-1"
/>
@ -208,6 +215,7 @@ export function PlaylistListPage() {
prev === 'asc' ? 'desc' : 'asc',
)
}
aria-label={t('playlists.sortToggle')}
>
<ArrowUpDown className="h-4 w-4" />
</Button>

View file

@ -1,6 +1,7 @@
/**
* SharedPlaylistPage public view of a playlist via share token (v0.10.4 F143)
* 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 { useQuery } from '@tanstack/react-query';
@ -8,6 +9,11 @@ import { Play, Shuffle, Copy } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
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 { PlaylistDetailPageCoverAndInfo } from './playlist-detail-page/PlaylistDetailPageCoverAndInfo';
import { PlaylistDetailPageSkeleton } from './playlist-detail-page/PlaylistDetailPageSkeleton';
@ -15,8 +21,23 @@ import { PlaylistDetailPageNotFound } from './playlist-detail-page/PlaylistDetai
import { PlaylistTrackList } from '../components/PlaylistTrackList';
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() {
const { token } = useParams<{ token: string }>();
const { t } = useTranslation();
const { play, addToQueue, clearQueue, toggleShuffle } = usePlayerStore();
const { data: playlist, isLoading, error } = useQuery({
queryKey: ['playlistShared', token],
@ -25,23 +46,48 @@ export function SharedPlaylistPage() {
});
if (isLoading) {
return <PlaylistDetailPageSkeleton />;
return (
<div className="min-h-screen bg-background">
<PlaylistDetailPageSkeleton />
</div>
);
}
if (error || !playlist) {
return <PlaylistDetailPageNotFound />;
return (
<div className="min-h-screen bg-background">
<PlaylistDetailPageNotFound />
</div>
);
}
const playlistTracks = playlist.tracks ?? [];
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 = () => {
navigator.clipboard.writeText(window.location.href);
toast.success('Link copied to clipboard');
toast.success(t('playlists.shared.linkCopied'));
};
return (
<div className="min-h-layout-page pb-24">
<div className="min-h-screen bg-background pb-32 relative">
<PlaylistDetailPageHero playlist={playlist} />
<div className="container mx-auto px-4 md:px-8 relative -mt-40 z-10">
<PlaylistDetailPageCoverAndInfo playlist={playlist} />
@ -49,15 +95,19 @@ export function SharedPlaylistPage() {
<Button
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"
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
size="lg"
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)]"
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>
<div className="flex-1" />
<Button
@ -65,14 +115,15 @@ export function SharedPlaylistPage() {
variant="outline"
className="rounded-full border-white/10 hover:bg-white/5"
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>
</div>
<Card variant="glass" className="overflow-hidden border-white/5">
<div className="p-4 border-b border-white/5 bg-black/20">
<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>
</div>
<div className="p-0">
@ -84,11 +135,16 @@ export function SharedPlaylistPage() {
onTracksReordered={() => {}}
enableDragAndDrop={false}
canRemoveTracks={false}
emptyMessage={t('playlists.shared.noTracks')}
emptyDescription={t('playlists.shared.noTracksDescription')}
className="divide-y divide-white/5"
/>
</div>
</Card>
</div>
<div className="fixed bottom-0 left-0 right-0 z-50">
<GlobalPlayer />
</div>
</div>
);
}

View file

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

View file

@ -1,6 +1,7 @@
import { useNavigate } from 'react-router-dom';
import { Play, Shuffle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useTranslation } from '@/hooks/useTranslation';
import { PlaylistActions } from '../../components/playlist-actions';
import { PlaylistFollowButton } from '../../components/PlaylistFollowButton';
import { DuplicatePlaylistButton } from '../../components/DuplicatePlaylistButton';
@ -20,6 +21,7 @@ export function PlaylistDetailPageActionsBar({
onShareClick,
onRefetch,
}: PlaylistDetailPageActionsBarProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const followerCount = (playlist as { follower_count?: number }).follower_count ?? 0;
const isFollowing = (playlist as { is_following?: boolean }).is_following ?? false;
@ -30,14 +32,14 @@ export function PlaylistDetailPageActionsBar({
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"
>
<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
size="lg"
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)]"
>
<Shuffle className="w-5 h-5 mr-2" /> Shuffle
<Shuffle className="w-5 h-5 mr-2" /> {t('playlists.shared.shuffle')}
</Button>
<div className="flex-1" />
<div className="flex items-center gap-2">

View file

@ -1,8 +1,9 @@
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 { Avatar } from '@/components/ui/avatar';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/hooks/useTranslation';
import type { Playlist } from '../../types';
interface PlaylistDetailPageCoverAndInfoProps {
@ -10,7 +11,11 @@ interface PlaylistDetailPageCoverAndInfoProps {
}
export function PlaylistDetailPageCoverAndInfo({ playlist }: PlaylistDetailPageCoverAndInfoProps) {
const { t } = useTranslation();
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 (
<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'
)}
>
{playlist.is_public ? 'Public Signal' : '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')}
{playlist.is_public ? t('playlists.shared.publicSignal') : t('playlists.shared.encrypted')}
</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>
<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">
<Avatar
className="w-6 h-6 border border-white/20"
fallback="U"
fallback={username.charAt(0).toUpperCase() || '?'}
src={playlist.user?.avatar_url}
/>
<span className="font-semibold">{playlist.user?.username}</span>
<span className="font-semibold">{username}</span>
</div>
<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>
<div className="flex items-center gap-2">
<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>

View file

@ -4,15 +4,26 @@ interface PlaylistDetailPageHeroProps {
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) {
const safeUrl = playlist.cover_url && isSafeUrl(playlist.cover_url) ? playlist.cover_url : null;
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" />
{playlist.cover_url && (
{safeUrl && (
<div
className="absolute inset-0 opacity-30 blur-3xl scale-110"
style={{
backgroundImage: `url(${playlist.cover_url})`,
backgroundImage: `url(${encodeURI(safeUrl)})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}

View file

@ -1,15 +1,18 @@
import { Link } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { useTranslation } from '@/hooks/useTranslation';
export function PlaylistDetailPageNotFound() {
const { t } = useTranslation();
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="text-9xl mb-4">👾</div>
<h2 className="text-3xl font-heading font-bold text-destructive mb-2">
Playlist Not Found
{t('playlists.shared.notFound')}
</h2>
<Button variant="outline" className="mt-8" asChild>
<Link to="/features/library">Back to Library</Link>
<Link to="/library">{t('playlists.shared.backToLibrary')}</Link>
</Button>
</div>
);

View file

@ -2,6 +2,7 @@ import { Plus, Users, Sparkles, Search } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { useTranslation } from '@/hooks/useTranslation';
import { PlaylistTrackList } from '../../components/PlaylistTrackList';
import { AddTrackToPlaylistModal } from '../../components/AddTrackToPlaylistModal';
import { CollaboratorList } from '../../components/CollaboratorList';
@ -54,6 +55,7 @@ export function PlaylistDetailPageTabs({
onTrackAdded,
onCollaboratorAdded,
}: PlaylistDetailPageTabsProps) {
const { t } = useTranslation();
return (
<>
<Tabs defaultValue="tracks" className="w-full">
@ -62,21 +64,21 @@ export function PlaylistDetailPageTabs({
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"
>
Tracks
{t('playlists.tracks')}
</TabsTrigger>
{permissions.canRead && (
<TabsTrigger
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"
>
Collaborators
{t('playlists.collaborators')}
</TabsTrigger>
)}
<TabsTrigger
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"
>
Recommendations
{t('playlists.detail.recommendations')}
</TabsTrigger>
</TabsList>
@ -86,7 +88,8 @@ export function PlaylistDetailPageTabs({
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<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"
/>
</div>
@ -97,7 +100,7 @@ export function PlaylistDetailPageTabs({
variant="ghost"
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>
)}
</div>
@ -121,11 +124,11 @@ export function PlaylistDetailPageTabs({
<Card variant="glass" className="p-6">
<div className="flex justify-between items-center mb-6">
<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>
{permissions.canManageCollaborators && (
<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>
)}
</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="flex items-center gap-2 mb-6">
<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>
<PlaylistRecommendations
limit={8}

View file

@ -4,10 +4,12 @@ import { usePlaylist } from '../../hooks/usePlaylist';
import { useQuery } from '@tanstack/react-query';
import { playlistsApi } from '@/services/api/playlists';
import { usePlaylistPermissions } from '../../hooks/usePlaylistPermissions';
import { useTranslation } from '@/hooks/useTranslation';
import toast from '@/utils/toast';
import type { Track } from '../../types';
export function usePlaylistDetailPage(playlistIdOverride?: string) {
const { t } = useTranslation();
const { id: paramId } = useParams<{ id: string }>();
const id = playlistIdOverride ?? paramId ?? '';
@ -30,15 +32,15 @@ export function usePlaylistDetailPage(playlistIdOverride?: string) {
const handleTrackAdded = () => {
setIsAddTrackModalOpen(false);
refetch();
toast.success('Track added');
toast.success(t('playlists.detail.trackAdded'));
};
const handleTrackRemoved = () => {
refetch();
toast.success('Track removed');
toast.success(t('playlists.detail.trackRemoved'));
};
const handleTracksReordered = () => {
refetch();
toast.success('Reordered');
toast.success(t('playlists.detail.reordered'));
};
const openShareModal = () => setIsShareModalOpen(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 { PlaylistDetailPage } from './pages/PlaylistDetailPage';
import { FavorisRedirectPage } from './pages/FavorisRedirectPage';
function PlaylistEditRedirect() {
const { id } = useParams<{ id: string }>();
return <Navigate to={`/playlists/${id}`} replace />;
}
export function PlaylistRoutes() {
return (
<Routes>
@ -10,10 +15,7 @@ export function PlaylistRoutes() {
<Route path="/favoris" element={<FavorisRedirectPage />} />
<Route path="/new" element={<Navigate to="/playlists" replace />} />
<Route path="/:id" element={<PlaylistDetailPage />} />
<Route
path="/:id/edit"
element={<Navigate to="/playlists/:id" replace />}
/>
<Route path="/:id/edit" element={<PlaylistEditRedirect />} />
<Route path="*" element={<Navigate to="/playlists" replace />} />
</Routes>
);

View file

@ -87,10 +87,15 @@ export async function createPlaylist(
*/
export async function getPlaylist(id: string): Promise<Playlist> {
return wrapPlaylistError(async () => {
const response = await apiClient.get<{ playlist: Playlist }>(
`/playlists/${id}`,
);
return response.data.playlist;
const response = await apiClient.get<Playlist>(`/playlists/${id}`);
// After interceptor unwrapping, response.data is the inner data object.
// Backend returns playlist flat in data (not nested under 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> {
return wrapPlaylistError(async () => {
const response = await apiClient.get<{ playlist: Playlist }>(
const response = await apiClient.get<Playlist>(
`/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