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:
parent
dfeff836ce
commit
9a4c0d2af4
163 changed files with 8642 additions and 1475 deletions
|
|
@ -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"]];
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 été 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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -37,11 +37,17 @@ export function usePasswordReset() {
|
|||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setSuccess(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return {
|
||||
handleRequestReset,
|
||||
handleReset,
|
||||
loading,
|
||||
error,
|
||||
success,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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 été 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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 été 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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue