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
|
// eslint-plugin-storybook optional: install if needed for Storybook-specific lint rules
|
||||||
// import storybook from "eslint-plugin-storybook";
|
// import storybook from "eslint-plugin-storybook";
|
||||||
|
|
||||||
|
|
@ -293,4 +296,4 @@ export default [js.configs.recommended, {
|
||||||
'*.config.cjs',
|
'*.config.cjs',
|
||||||
'**/ui.backup/**',
|
'**/ui.backup/**',
|
||||||
],
|
],
|
||||||
}];
|
}, ...storybook.configs["flat/recommended"]];
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { fn } from '@storybook/test';
|
import { fn } from 'storybook/test';
|
||||||
import { UserTableRow } from './UserTableRow';
|
import { UserTableRow } from './UserTableRow';
|
||||||
import { User } from '@/types';
|
import { User } from '@/types';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { fn } from '@storybook/test';
|
import { fn } from 'storybook/test';
|
||||||
import { BanUserModal } from './BanUserModal';
|
import { BanUserModal } from './BanUserModal';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { CartItem } from './CartItem';
|
import { CartItem } from './CartItem';
|
||||||
import { fn } from '@storybook/test';
|
import { fn } from 'storybook/test';
|
||||||
import type { CartItem as CartItemType } from '@/stores/cartStore';
|
import type { CartItem as CartItemType } from '@/stores/cartStore';
|
||||||
|
|
||||||
const MOCK_ITEM = {
|
const MOCK_ITEM = {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { CreateAPIKeyModal } from './CreateAPIKeyModal';
|
import { CreateAPIKeyModal } from './CreateAPIKeyModal';
|
||||||
import { fn } from '@storybook/test';
|
import { fn } from 'storybook/test';
|
||||||
|
|
||||||
const meta: Meta<typeof CreateAPIKeyModal> = {
|
const meta: Meta<typeof CreateAPIKeyModal> = {
|
||||||
title: 'Components/Features/Developer/Modals/CreateAPIKeyModal',
|
title: 'Components/Features/Developer/Modals/CreateAPIKeyModal',
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { fn } from '@storybook/test';
|
import { fn } from 'storybook/test';
|
||||||
import { EquipmentCard } from './EquipmentCard';
|
import { EquipmentCard } from './EquipmentCard';
|
||||||
import { GearItem } from '../../types';
|
import { GearItem } from '../../types';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { fn } from '@storybook/test';
|
import { fn } from 'storybook/test';
|
||||||
import { InventoryView } from './InventoryView';
|
import { InventoryView } from './InventoryView';
|
||||||
import { ToastProvider } from '@/components/feedback/ToastProvider';
|
import { ToastProvider } from '@/components/feedback/ToastProvider';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { fn } from '@storybook/test';
|
import { fn } from 'storybook/test';
|
||||||
import { LiveStreamDetailView } from './LiveStreamDetailView';
|
import { LiveStreamDetailView } from './LiveStreamDetailView';
|
||||||
import { ToastProvider } from '@/components/feedback/ToastProvider';
|
import { ToastProvider } from '@/components/feedback/ToastProvider';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { fn } from '@storybook/test';
|
import { fn } from 'storybook/test';
|
||||||
import { TipStreamerModal } from './TipStreamerModal';
|
import { TipStreamerModal } from './TipStreamerModal';
|
||||||
import { ToastProvider } from '@/components/feedback/ToastProvider';
|
import { ToastProvider } from '@/components/feedback/ToastProvider';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { CreatorModal } from './CreatorModal';
|
import { CreatorModal } from './CreatorModal';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { useArgs } from '@storybook/preview-api';
|
import { useArgs } from 'storybook/preview-api';
|
||||||
|
|
||||||
const meta: Meta = {
|
const meta: Meta = {
|
||||||
title: 'Components/Features/Modals/CreatorModal',
|
title: 'Components/Features/Modals/CreatorModal',
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { fn } from '@storybook/test';
|
import { fn } from 'storybook/test';
|
||||||
import { Search } from './Search';
|
import { Search } from './Search';
|
||||||
|
|
||||||
const mockResults = [
|
const mockResults = [
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { fn } from '@storybook/test';
|
import { fn } from 'storybook/test';
|
||||||
import { SearchBar } from './SearchBar';
|
import { SearchBar } from './SearchBar';
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { fn } from '@storybook/test';
|
import { fn } from 'storybook/test';
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { SellerDashboardView } from './SellerDashboardView';
|
import { SellerDashboardView } from './SellerDashboardView';
|
||||||
import { ToastProvider } from '@/components/feedback/ToastProvider';
|
import { ToastProvider } from '@/components/feedback/ToastProvider';
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { fn } from '@storybook/test';
|
import { fn } from 'storybook/test';
|
||||||
import { FlashSaleModal } from './FlashSaleModal';
|
import { FlashSaleModal } from './FlashSaleModal';
|
||||||
import { ToastProvider } from '@/components/feedback/ToastProvider';
|
import { ToastProvider } from '@/components/feedback/ToastProvider';
|
||||||
import { Product } from '../../../types';
|
import { Product } from '../../../types';
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { fn } from '@storybook/test';
|
import { fn } from 'storybook/test';
|
||||||
import { ChangeEmailModal } from './ChangeEmailModal';
|
import { ChangeEmailModal } from './ChangeEmailModal';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { fn } from '@storybook/test';
|
import { fn } from 'storybook/test';
|
||||||
import { ChangeUsernameModal } from './ChangeUsernameModal';
|
import { ChangeUsernameModal } from './ChangeUsernameModal';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { fn } from '@storybook/test';
|
import { fn } from 'storybook/test';
|
||||||
import { within, userEvent } from '@storybook/test';
|
import { within, userEvent } from 'storybook/test';
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { TwoFactorSetup, TwoFactorSetupSkeleton } from './TwoFactorSetup';
|
import { TwoFactorSetup, TwoFactorSetupSkeleton } from './TwoFactorSetup';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { fn } from '@storybook/test';
|
import { fn } from 'storybook/test';
|
||||||
import { CommentItem } from './CommentItem';
|
import { CommentItem } from './CommentItem';
|
||||||
|
|
||||||
const mockComment = {
|
const mockComment = {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { fn } from '@storybook/test';
|
import { fn } from 'storybook/test';
|
||||||
import { CreatePostModal } from './CreatePostModal';
|
import { CreatePostModal } from './CreatePostModal';
|
||||||
|
|
||||||
const meta: Meta<typeof CreatePostModal> = {
|
const meta: Meta<typeof CreatePostModal> = {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { fn } from '@storybook/test';
|
import { fn } from 'storybook/test';
|
||||||
import { CreateGroupModal } from './CreateGroupModal';
|
import { CreateGroupModal } from './CreateGroupModal';
|
||||||
import { ToastProvider } from '@/components/feedback/ToastProvider';
|
import { ToastProvider } from '@/components/feedback/ToastProvider';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { fn } from '@storybook/test';
|
import { fn } from 'storybook/test';
|
||||||
import { GroupsView } from './GroupsView';
|
import { GroupsView } from './GroupsView';
|
||||||
import { ToastProvider } from '@/components/feedback/ToastProvider';
|
import { ToastProvider } from '@/components/feedback/ToastProvider';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { fn } from '@storybook/test';
|
import { fn } from 'storybook/test';
|
||||||
import { BulkUploadModal } from './BulkUploadModal';
|
import { BulkUploadModal } from './BulkUploadModal';
|
||||||
|
|
||||||
const meta: Meta<typeof BulkUploadModal> = {
|
const meta: Meta<typeof BulkUploadModal> = {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { fn } from '@storybook/test';
|
import { fn } from 'storybook/test';
|
||||||
import { CoverArtUploadModal } from './CoverArtUploadModal';
|
import { CoverArtUploadModal } from './CoverArtUploadModal';
|
||||||
|
|
||||||
const meta: Meta<typeof CoverArtUploadModal> = {
|
const meta: Meta<typeof CoverArtUploadModal> = {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { LyricsEditorModal } from './LyricsEditorModal';
|
import { LyricsEditorModal } from './LyricsEditorModal';
|
||||||
import { fn } from '@storybook/test';
|
import { fn } from 'storybook/test';
|
||||||
|
|
||||||
const meta: Meta<typeof LyricsEditorModal> = {
|
const meta: Meta<typeof LyricsEditorModal> = {
|
||||||
title: 'Components/Features/Upload/Metadata/LyricsEditorModal',
|
title: 'Components/Features/Upload/Metadata/LyricsEditorModal',
|
||||||
|
|
|
||||||
|
|
@ -30,14 +30,7 @@ export function AuthButton({
|
||||||
aria-disabled={disabled || loading ? 'true' : 'false'}
|
aria-disabled={disabled || loading ? 'true' : 'false'}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{children}
|
||||||
<>
|
|
||||||
<span className="sr-only">Chargement en cours</span>
|
|
||||||
<span aria-hidden="true">Chargement...</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
children
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useId, useState } from 'react';
|
import React, { useId, useState } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Eye, EyeOff } from 'lucide-react';
|
import { Eye, EyeOff } from 'lucide-react';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
|
||||||
interface AuthInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
interface AuthInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
error?: string;
|
error?: string;
|
||||||
|
|
@ -17,8 +18,7 @@ export function AuthInput({
|
||||||
type,
|
type,
|
||||||
...props
|
...props
|
||||||
}: AuthInputProps) {
|
}: AuthInputProps) {
|
||||||
// CRITIQUE FIX #5: Utiliser useId() de React pour générer un ID stable
|
const { t } = useTranslation();
|
||||||
// qui ne change pas entre les renders, contrairement à Math.random()
|
|
||||||
const generatedId = useId();
|
const generatedId = useId();
|
||||||
const inputId = id || generatedId;
|
const inputId = id || generatedId;
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
@ -76,8 +76,7 @@ export function AuthInput({
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-opacity transition-colors duration-[var(--duration-fast)]"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-opacity transition-colors duration-[var(--duration-fast)]"
|
||||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
aria-label={showPassword ? t('common.hidePassword') : t('common.showPassword')}
|
||||||
tabIndex={-1}
|
|
||||||
>
|
>
|
||||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
|
|
||||||
|
|
@ -19,14 +20,15 @@ export function AuthLayout({
|
||||||
footerLinks,
|
footerLinks,
|
||||||
className,
|
className,
|
||||||
}: AuthLayoutProps) {
|
}: AuthLayoutProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div
|
<main
|
||||||
|
id="main-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
'min-h-screen flex items-center justify-center bg-background py-12 px-4 sm:px-6 lg:px-8 relative overflow-hidden',
|
'min-h-screen flex items-center justify-center bg-background py-12 px-4 sm:px-6 lg:px-8 relative overflow-hidden',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
role="main"
|
aria-label={t('auth.layout.pageLabel')}
|
||||||
aria-label="Page d'authentification"
|
|
||||||
>
|
>
|
||||||
{/* Background — Sumi-e ink wash atmosphere */}
|
{/* Background — Sumi-e ink wash atmosphere */}
|
||||||
<div className="fixed inset-0 bg-[var(--sumi-bg-void)]">
|
<div className="fixed inset-0 bg-[var(--sumi-bg-void)]">
|
||||||
|
|
@ -95,7 +97,7 @@ export function AuthLayout({
|
||||||
{footerLinks && footerLinks.length > 0 && (
|
{footerLinks && footerLinks.length > 0 && (
|
||||||
<nav
|
<nav
|
||||||
className="text-center space-x-6"
|
className="text-center space-x-6"
|
||||||
aria-label="Navigation d'authentification"
|
aria-label={t('auth.layout.navLabel')}
|
||||||
>
|
>
|
||||||
{footerLinks.map((link) => (
|
{footerLinks.map((link) => (
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -110,6 +112,6 @@ export function AuthLayout({
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { within, userEvent } from '@storybook/test';
|
import { within, userEvent } from 'storybook/test';
|
||||||
import { ForgotPasswordForm } from './ForgotPasswordForm';
|
import { ForgotPasswordForm } from './ForgotPasswordForm';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Github } from 'lucide-react';
|
import { Github } from 'lucide-react';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
|
||||||
interface OAuthButtonProps {
|
interface OAuthButtonProps {
|
||||||
provider: 'google' | 'github' | 'discord' | 'spotify';
|
provider: 'google' | 'github' | 'discord' | 'spotify';
|
||||||
|
|
@ -58,13 +59,14 @@ const providerConfig = {
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export function OAuthButton({ provider, onClick, className }: OAuthButtonProps) {
|
export function OAuthButton({ provider, onClick, className }: OAuthButtonProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const config = providerConfig[provider];
|
const config = providerConfig[provider];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
aria-label={config.ariaLabel}
|
aria-label={t('auth.login.oauthProvider', { provider: config.label })}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full flex items-center justify-center gap-3 px-4 py-2.5 rounded-lg',
|
'w-full flex items-center justify-center gap-3 px-4 py-2.5 rounded-lg',
|
||||||
'bg-muted/50 border border-border text-foreground',
|
'bg-muted/50 border border-border text-foreground',
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Check, X } from 'lucide-react';
|
import { Check, X } from 'lucide-react';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
|
||||||
interface PasswordStrengthIndicatorProps {
|
interface PasswordStrengthIndicatorProps {
|
||||||
password: string;
|
password: string;
|
||||||
|
|
@ -10,42 +12,42 @@ interface StrengthResult {
|
||||||
label: string;
|
label: string;
|
||||||
color: string;
|
color: string;
|
||||||
textColor: string;
|
textColor: string;
|
||||||
requirements: Array<{ text: string; met: boolean }>;
|
requirements: Array<{ key: string; text: string; met: boolean }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStrength(pwd: string): StrengthResult {
|
function getStrength(pwd: string, t: TFunction): StrengthResult {
|
||||||
const requirements: Array<{ text: string; met: boolean }> = [];
|
const requirements: Array<{ key: string; text: string; met: boolean }> = [];
|
||||||
let strength = 0;
|
let strength = 0;
|
||||||
|
|
||||||
// Minimum 12 caractères (requis)
|
|
||||||
const hasLength = pwd.length >= 12;
|
const hasLength = pwd.length >= 12;
|
||||||
if (hasLength) strength++;
|
if (hasLength) strength++;
|
||||||
requirements.push({
|
requirements.push({
|
||||||
text: `Au moins 12 caractères (${pwd.length}/12)`,
|
key: 'length',
|
||||||
|
text: t('auth.register.passwordStrength.reqLength', { current: pwd.length }),
|
||||||
met: hasLength,
|
met: hasLength,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Majuscule et minuscule
|
|
||||||
const hasCase = /[a-z]/.test(pwd) && /[A-Z]/.test(pwd);
|
const hasCase = /[a-z]/.test(pwd) && /[A-Z]/.test(pwd);
|
||||||
if (hasCase) strength++;
|
if (hasCase) strength++;
|
||||||
requirements.push({
|
requirements.push({
|
||||||
text: 'Majuscule et minuscule',
|
key: 'case',
|
||||||
|
text: t('auth.register.passwordStrength.reqCase'),
|
||||||
met: hasCase,
|
met: hasCase,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Chiffre
|
|
||||||
const hasDigit = /\d/.test(pwd);
|
const hasDigit = /\d/.test(pwd);
|
||||||
if (hasDigit) strength++;
|
if (hasDigit) strength++;
|
||||||
requirements.push({
|
requirements.push({
|
||||||
text: 'Un chiffre',
|
key: 'digit',
|
||||||
|
text: t('auth.register.passwordStrength.reqDigit'),
|
||||||
met: hasDigit,
|
met: hasDigit,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Caractère spécial
|
|
||||||
const hasSpecial = /[^a-zA-Z\d]/.test(pwd);
|
const hasSpecial = /[^a-zA-Z\d]/.test(pwd);
|
||||||
if (hasSpecial) strength++;
|
if (hasSpecial) strength++;
|
||||||
requirements.push({
|
requirements.push({
|
||||||
text: 'Un caractère spécial (!@#$%^&*...)',
|
key: 'special',
|
||||||
|
text: t('auth.register.passwordStrength.reqSpecial'),
|
||||||
met: hasSpecial,
|
met: hasSpecial,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -53,19 +55,19 @@ function getStrength(pwd: string): StrengthResult {
|
||||||
let color: string;
|
let color: string;
|
||||||
let textColor: string;
|
let textColor: string;
|
||||||
if (strength <= 1) {
|
if (strength <= 1) {
|
||||||
label = 'Weak';
|
label = t('auth.register.passwordStrength.weak');
|
||||||
color = 'bg-destructive';
|
color = 'bg-destructive';
|
||||||
textColor = 'text-destructive';
|
textColor = 'text-destructive';
|
||||||
} else if (strength === 2) {
|
} else if (strength === 2) {
|
||||||
label = 'Fair';
|
label = t('auth.register.passwordStrength.fair');
|
||||||
color = 'bg-warning';
|
color = 'bg-warning';
|
||||||
textColor = 'text-warning';
|
textColor = 'text-warning';
|
||||||
} else if (strength === 3) {
|
} else if (strength === 3) {
|
||||||
label = 'Good';
|
label = t('auth.register.passwordStrength.good');
|
||||||
color = 'bg-warning';
|
color = 'bg-warning';
|
||||||
textColor = 'text-warning';
|
textColor = 'text-warning';
|
||||||
} else {
|
} else {
|
||||||
label = 'Strong';
|
label = t('auth.register.passwordStrength.strong');
|
||||||
color = 'bg-success';
|
color = 'bg-success';
|
||||||
textColor = 'text-success';
|
textColor = 'text-success';
|
||||||
}
|
}
|
||||||
|
|
@ -76,9 +78,11 @@ function getStrength(pwd: string): StrengthResult {
|
||||||
export function PasswordStrengthIndicator({
|
export function PasswordStrengthIndicator({
|
||||||
password,
|
password,
|
||||||
}: PasswordStrengthIndicatorProps) {
|
}: PasswordStrengthIndicatorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (!password) return null;
|
if (!password) return null;
|
||||||
|
|
||||||
const { level, label, color, textColor, requirements } = getStrength(password);
|
const { level, label, color, textColor, requirements } = getStrength(password, t);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -98,7 +102,7 @@ export function PasswordStrengthIndicator({
|
||||||
aria-valuenow={segment === 1 ? level : undefined}
|
aria-valuenow={segment === 1 ? level : undefined}
|
||||||
aria-valuemin={segment === 1 ? 0 : undefined}
|
aria-valuemin={segment === 1 ? 0 : undefined}
|
||||||
aria-valuemax={segment === 1 ? 4 : undefined}
|
aria-valuemax={segment === 1 ? 4 : undefined}
|
||||||
aria-label={segment === 1 ? `Force du mot de passe: ${label}` : undefined}
|
aria-label={segment === 1 ? t('auth.register.passwordStrength.label', { level: label }) : undefined}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -118,7 +122,7 @@ export function PasswordStrengthIndicator({
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{requirements.map((req) => (
|
{requirements.map((req) => (
|
||||||
<li
|
<li
|
||||||
key={req.text}
|
key={req.key}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-2 text-xs transition-colors duration-[var(--duration-fast)]',
|
'flex items-center gap-2 text-xs transition-colors duration-[var(--duration-fast)]',
|
||||||
req.met ? 'text-success' : 'text-muted-foreground',
|
req.met ? 'text-success' : 'text-muted-foreground',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { fn } from '@storybook/test';
|
import { fn } from 'storybook/test';
|
||||||
import { within, userEvent } from '@storybook/test';
|
import { within, userEvent } from 'storybook/test';
|
||||||
import { TwoFactorVerify } from './TwoFactorVerify';
|
import { TwoFactorVerify } from './TwoFactorVerify';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { Label } from '@/components/ui/label';
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
import { Loader2, Shield, AlertCircle } from 'lucide-react';
|
import { Loader2, Shield, AlertCircle } from 'lucide-react';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { parseApiError } from '@/utils/apiErrorHandler';
|
import { parseApiError } from '@/utils/apiErrorHandler';
|
||||||
|
|
||||||
interface TwoFactorVerifyProps {
|
interface TwoFactorVerifyProps {
|
||||||
|
|
@ -22,6 +23,7 @@ interface TwoFactorVerifyProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TwoFactorVerify({ onSuccess, onCancel, isSubmitting = false }: TwoFactorVerifyProps) {
|
export function TwoFactorVerify({ onSuccess, onCancel, isSubmitting = false }: TwoFactorVerifyProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [code, setCode] = useState('');
|
const [code, setCode] = useState('');
|
||||||
const [backupCode, setBackupCode] = useState('');
|
const [backupCode, setBackupCode] = useState('');
|
||||||
const [useBackupCode, setUseBackupCode] = useState(false);
|
const [useBackupCode, setUseBackupCode] = useState(false);
|
||||||
|
|
@ -32,7 +34,7 @@ export function TwoFactorVerify({ onSuccess, onCancel, isSubmitting = false }: T
|
||||||
|
|
||||||
const handleVerify = async () => {
|
const handleVerify = async () => {
|
||||||
if (!code && !backupCode) {
|
if (!code && !backupCode) {
|
||||||
setError('Please enter a verification code');
|
setError(t('auth.twoFactor.enterCodeError'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,18 +64,17 @@ export function TwoFactorVerify({ onSuccess, onCancel, isSubmitting = false }: T
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Shield className="h-5 w-5 text-muted-foreground" />
|
<Shield className="h-5 w-5 text-muted-foreground" />
|
||||||
Two-Factor Authentication
|
{t('auth.twoFactor.title')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Enter the code from your authenticator app
|
{t('auth.twoFactor.subtitle')}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Enter the 6-digit code from your authenticator app to continue
|
{t('auth.twoFactor.enterCode')}
|
||||||
signing in.
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
|
|
@ -87,7 +88,7 @@ export function TwoFactorVerify({ onSuccess, onCancel, isSubmitting = false }: T
|
||||||
{!useBackupCode ? (
|
{!useBackupCode ? (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="2fa-code">Verification Code</Label>
|
<Label htmlFor="2fa-code">{t('auth.twoFactor.verificationCode')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="2fa-code"
|
id="2fa-code"
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -103,23 +104,23 @@ export function TwoFactorVerify({ onSuccess, onCancel, isSubmitting = false }: T
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Lost access?{' '}
|
{t('auth.twoFactor.lostAccess')}{' '}
|
||||||
<button
|
<button
|
||||||
onClick={() => setUseBackupCode(true)}
|
onClick={() => setUseBackupCode(true)}
|
||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
Use a backup code
|
{t('auth.twoFactor.useBackupCode')}
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="backup-code">Backup Code</Label>
|
<Label htmlFor="backup-code">{t('auth.twoFactor.backupCode')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="backup-code"
|
id="backup-code"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Enter backup code"
|
placeholder={t('auth.twoFactor.backupCode')}
|
||||||
value={backupCode}
|
value={backupCode}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setBackupCode(e.target.value);
|
setBackupCode(e.target.value);
|
||||||
|
|
@ -132,7 +133,7 @@ export function TwoFactorVerify({ onSuccess, onCancel, isSubmitting = false }: T
|
||||||
onClick={() => setUseBackupCode(false)}
|
onClick={() => setUseBackupCode(false)}
|
||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
Use authenticator code instead
|
{t('auth.twoFactor.useAuthenticator')}
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
|
|
@ -140,7 +141,7 @@ export function TwoFactorVerify({ onSuccess, onCancel, isSubmitting = false }: T
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button onClick={onCancel} variant="outline" className="flex-1" disabled={busy}>
|
<Button onClick={onCancel} variant="outline" className="flex-1" disabled={busy}>
|
||||||
Cancel
|
{t('auth.twoFactor.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleVerify}
|
onClick={handleVerify}
|
||||||
|
|
@ -150,10 +151,10 @@ export function TwoFactorVerify({ onSuccess, onCancel, isSubmitting = false }: T
|
||||||
{busy ? (
|
{busy ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Verifying...
|
{t('auth.twoFactor.verifying')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Verify'
|
t('auth.twoFactor.verify')
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import { Navigate } from 'react-router-dom';
|
import { Navigate } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { AuthLayout } from '../AuthLayout';
|
import { AuthLayout } from '../AuthLayout';
|
||||||
import { useRegisterPage } from './useRegisterPage';
|
import { useRegisterPage } from './useRegisterPage';
|
||||||
import { RegisterPageForm } from './RegisterPageForm';
|
import { RegisterPageForm } from './RegisterPageForm';
|
||||||
import { RegisterPageVerificationNotice } from './RegisterPageVerificationNotice';
|
import { RegisterPageVerificationNotice } from './RegisterPageVerificationNotice';
|
||||||
|
|
||||||
export function RegisterPage() {
|
export function RegisterPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { isAuthenticated } = useAuthStore();
|
const { isAuthenticated } = useAuthStore();
|
||||||
const {
|
const {
|
||||||
formData,
|
formData,
|
||||||
|
|
@ -20,6 +22,7 @@ export function RegisterPage() {
|
||||||
error,
|
error,
|
||||||
resendLoading,
|
resendLoading,
|
||||||
resendSuccess,
|
resendSuccess,
|
||||||
|
resendError,
|
||||||
handleChange,
|
handleChange,
|
||||||
handleBlur,
|
handleBlur,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
|
@ -32,15 +35,16 @@ export function RegisterPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthLayout
|
<AuthLayout
|
||||||
title="Inscription"
|
title={t('auth.register.title')}
|
||||||
subtitle="Créez votre compte"
|
subtitle={t('auth.register.subtitle')}
|
||||||
footerLinks={[{ label: 'Déjà un compte ? Se connecter', to: '/login' }]}
|
footerLinks={[{ label: t('auth.register.footerLink'), to: '/login' }]}
|
||||||
>
|
>
|
||||||
{showVerificationNotice ? (
|
{showVerificationNotice ? (
|
||||||
<RegisterPageVerificationNotice
|
<RegisterPageVerificationNotice
|
||||||
email={formData.email}
|
email={formData.email}
|
||||||
resendLoading={resendLoading}
|
resendLoading={resendLoading}
|
||||||
resendSuccess={resendSuccess}
|
resendSuccess={resendSuccess}
|
||||||
|
resendError={resendError}
|
||||||
onResend={handleResendVerificationEmail}
|
onResend={handleResendVerificationEmail}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { AlertCircle, Loader2, Check, X } from 'lucide-react';
|
import { AlertCircle, Loader2, Check, X } from 'lucide-react';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { AuthInput } from '../AuthInput';
|
import { AuthInput } from '../AuthInput';
|
||||||
import { AuthButton } from '../AuthButton';
|
import { AuthButton } from '../AuthButton';
|
||||||
import { PasswordStrengthIndicator } from '../PasswordStrengthIndicator';
|
import { PasswordStrengthIndicator } from '../PasswordStrengthIndicator';
|
||||||
|
|
@ -36,11 +37,13 @@ export function RegisterPageForm({
|
||||||
onFieldBlur,
|
onFieldBlur,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: RegisterPageFormProps) {
|
}: RegisterPageFormProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
aria-label="Formulaire d'inscription"
|
aria-label={t('auth.register.formAriaLabel')}
|
||||||
data-testid="register-form"
|
data-testid="register-form"
|
||||||
>
|
>
|
||||||
{error && (
|
{error && (
|
||||||
|
|
@ -60,7 +63,7 @@ export function RegisterPageForm({
|
||||||
<AuthInput
|
<AuthInput
|
||||||
id="register-username"
|
id="register-username"
|
||||||
type="text"
|
type="text"
|
||||||
label="Nom d'utilisateur"
|
label={t('auth.register.username')}
|
||||||
value={formData.username}
|
value={formData.username}
|
||||||
onChange={(e) => onFieldChange('username', e.target.value)}
|
onChange={(e) => onFieldChange('username', e.target.value)}
|
||||||
onBlur={() => onFieldBlur('username')}
|
onBlur={() => onFieldBlur('username')}
|
||||||
|
|
@ -73,17 +76,17 @@ export function RegisterPageForm({
|
||||||
{checkingUsername ? (
|
{checkingUsername ? (
|
||||||
<p className="text-xs text-muted-foreground flex items-center gap-1.5" role="status">
|
<p className="text-xs text-muted-foreground flex items-center gap-1.5" role="status">
|
||||||
<span className="h-3 w-3 border-2 border-muted-foreground border-t-transparent rounded-full animate-spin" />
|
<span className="h-3 w-3 border-2 border-muted-foreground border-t-transparent rounded-full animate-spin" />
|
||||||
<span>Vérification...</span>
|
<span>{t('auth.register.usernameCheck.checking')}</span>
|
||||||
</p>
|
</p>
|
||||||
) : usernameAvailable === true ? (
|
) : usernameAvailable === true ? (
|
||||||
<p className="text-xs text-success flex items-center gap-1.5" role="status">
|
<p className="text-xs text-success flex items-center gap-1.5" role="status">
|
||||||
<Check className="h-3 w-3" />
|
<Check className="h-3 w-3" />
|
||||||
<span>Ce nom d'utilisateur est disponible</span>
|
<span>{t('auth.register.usernameCheck.available')}</span>
|
||||||
</p>
|
</p>
|
||||||
) : usernameAvailable === false ? (
|
) : usernameAvailable === false ? (
|
||||||
<p className="text-xs text-destructive flex items-center gap-1.5" role="alert">
|
<p className="text-xs text-destructive flex items-center gap-1.5" role="alert">
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
<span>Ce nom d'utilisateur est déjà pris</span>
|
<span>{t('auth.register.usernameCheck.unavailable')}</span>
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -94,7 +97,7 @@ export function RegisterPageForm({
|
||||||
<AuthInput
|
<AuthInput
|
||||||
id="register-email"
|
id="register-email"
|
||||||
type="email"
|
type="email"
|
||||||
label="Email"
|
label={t('auth.register.email')}
|
||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={(e) => onFieldChange('email', e.target.value)}
|
onChange={(e) => onFieldChange('email', e.target.value)}
|
||||||
onBlur={() => onFieldBlur('email')}
|
onBlur={() => onFieldBlur('email')}
|
||||||
|
|
@ -108,7 +111,7 @@ export function RegisterPageForm({
|
||||||
<AuthInput
|
<AuthInput
|
||||||
id="register-password"
|
id="register-password"
|
||||||
type="password"
|
type="password"
|
||||||
label="Mot de passe"
|
label={t('auth.register.password')}
|
||||||
value={formData.password}
|
value={formData.password}
|
||||||
onChange={(e) => onFieldChange('password', e.target.value)}
|
onChange={(e) => onFieldChange('password', e.target.value)}
|
||||||
onBlur={() => onFieldBlur('password')}
|
onBlur={() => onFieldBlur('password')}
|
||||||
|
|
@ -124,7 +127,7 @@ export function RegisterPageForm({
|
||||||
<AuthInput
|
<AuthInput
|
||||||
id="register-password_confirm"
|
id="register-password_confirm"
|
||||||
type="password"
|
type="password"
|
||||||
label="Confirmer le mot de passe"
|
label={t('auth.register.confirmPassword')}
|
||||||
value={formData.password_confirm}
|
value={formData.password_confirm}
|
||||||
onChange={(e) => onFieldChange('password_confirm', e.target.value)}
|
onChange={(e) => onFieldChange('password_confirm', e.target.value)}
|
||||||
onBlur={() => onFieldBlur('password_confirm')}
|
onBlur={() => onFieldBlur('password_confirm')}
|
||||||
|
|
@ -147,30 +150,30 @@ export function RegisterPageForm({
|
||||||
}}
|
}}
|
||||||
required
|
required
|
||||||
aria-invalid={errors.terms ? 'true' : 'false'}
|
aria-invalid={errors.terms ? 'true' : 'false'}
|
||||||
aria-describedby={errors.terms ? 'terms-error' : 'terms-description'}
|
aria-describedby={errors.terms ? 'terms-description terms-error' : 'terms-description'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<label htmlFor="register-terms" className="text-sm text-muted-foreground leading-relaxed cursor-pointer inline-flex flex-wrap gap-x-1">
|
<label htmlFor="register-terms" className="text-sm text-muted-foreground leading-relaxed cursor-pointer inline-flex flex-wrap gap-x-1">
|
||||||
<span>J'accepte les</span>
|
<span>{t('auth.register.terms.accept')}</span>
|
||||||
<Link
|
<Link
|
||||||
to="/terms"
|
to="/terms"
|
||||||
className="text-foreground hover:underline underline-offset-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded"
|
className="text-foreground hover:underline underline-offset-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded"
|
||||||
aria-label="Lire les conditions d'utilisation"
|
aria-label={t('auth.register.terms.termsAriaLabel')}
|
||||||
>
|
>
|
||||||
conditions d'utilisation
|
{t('auth.register.terms.termsOfService')}
|
||||||
</Link>
|
</Link>
|
||||||
<span>et la</span>
|
<span>{t('auth.register.terms.and')}</span>
|
||||||
<Link
|
<Link
|
||||||
to="/privacy"
|
to="/privacy"
|
||||||
className="text-foreground hover:underline underline-offset-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded"
|
className="text-foreground hover:underline underline-offset-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded"
|
||||||
aria-label="Lire la politique de confidentialité"
|
aria-label={t('auth.register.terms.privacyAriaLabel')}
|
||||||
>
|
>
|
||||||
politique de confidentialité
|
{t('auth.register.terms.privacyPolicy')}
|
||||||
</Link>
|
</Link>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p id="terms-description" className="sr-only">
|
<p id="terms-description" className="sr-only">
|
||||||
Vous devez accepter les conditions d'utilisation et la politique de confidentialité pour créer un compte
|
{t('auth.register.terms.description')}
|
||||||
</p>
|
</p>
|
||||||
{errors.terms && (
|
{errors.terms && (
|
||||||
<p id="terms-error" className="text-sm text-destructive animate-shake" role="alert">
|
<p id="terms-error" className="text-sm text-destructive animate-shake" role="alert">
|
||||||
|
|
@ -187,10 +190,10 @@ export function RegisterPageForm({
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
Inscription en cours...
|
{t('auth.register.loadingText')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"S'inscrire"
|
t('auth.register.registerButton')
|
||||||
)}
|
)}
|
||||||
</AuthButton>
|
</AuthButton>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { AuthButton } from '../AuthButton';
|
import { AuthButton } from '../AuthButton';
|
||||||
import { Loader2, Mail, CheckCircle2 } from 'lucide-react';
|
import { Loader2, Mail, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
|
||||||
interface RegisterPageVerificationNoticeProps {
|
interface RegisterPageVerificationNoticeProps {
|
||||||
email: string;
|
email: string;
|
||||||
resendLoading: boolean;
|
resendLoading: boolean;
|
||||||
resendSuccess: boolean;
|
resendSuccess: boolean;
|
||||||
|
resendError: string | null;
|
||||||
onResend: () => void;
|
onResend: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -12,8 +14,11 @@ export function RegisterPageVerificationNotice({
|
||||||
email,
|
email,
|
||||||
resendLoading,
|
resendLoading,
|
||||||
resendSuccess,
|
resendSuccess,
|
||||||
|
resendError,
|
||||||
onResend,
|
onResend,
|
||||||
}: RegisterPageVerificationNoticeProps) {
|
}: RegisterPageVerificationNoticeProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-center space-y-5 animate-fade-in py-4" role="status" aria-live="polite">
|
<div className="text-center space-y-5 animate-fade-in py-4" role="status" aria-live="polite">
|
||||||
{/* Success icon */}
|
{/* Success icon */}
|
||||||
|
|
@ -24,15 +29,15 @@ export function RegisterPageVerificationNotice({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-lg font-semibold text-foreground">Inscription réussie !</p>
|
<p className="text-lg font-semibold text-foreground">{t('auth.register.verification.title')}</p>
|
||||||
<p className="text-sm text-muted-foreground mt-2">
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
Un email de vérification a été envoyé à{' '}
|
{t('auth.register.verification.emailSent')}{' '}
|
||||||
<span className="font-medium text-foreground">{email}</span>
|
<span className="font-medium text-foreground">{email}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Veuillez vérifier votre boîte mail et cliquer sur le lien de vérification.
|
{t('auth.register.verification.checkInbox')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{resendSuccess && (
|
{resendSuccess && (
|
||||||
|
|
@ -42,7 +47,18 @@ export function RegisterPageVerificationNotice({
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
<CheckCircle2 className="h-4 w-4" />
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
<span>Email de vérification renvoyé avec succès !</span>
|
<span>{t('auth.register.verification.resendSuccess')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{resendError && (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center gap-2 text-sm text-destructive animate-fade-in"
|
||||||
|
role="alert"
|
||||||
|
aria-live="assertive"
|
||||||
|
>
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<span>{resendError}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -51,15 +67,15 @@ export function RegisterPageVerificationNotice({
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={onResend}
|
onClick={onResend}
|
||||||
disabled={resendLoading}
|
disabled={resendLoading}
|
||||||
aria-label="Renvoyer l'email de vérification"
|
aria-label={t('auth.register.verification.resendButton')}
|
||||||
>
|
>
|
||||||
{resendLoading ? (
|
{resendLoading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin inline" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin inline" />
|
||||||
Envoi en cours...
|
{t('auth.register.verification.resendLoading')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Renvoyer l'email de vérification"
|
t('auth.register.verification.resendButton')
|
||||||
)}
|
)}
|
||||||
</AuthButton>
|
</AuthButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRegister } from '../../hooks/useRegister';
|
import { useRegister } from '../../hooks/useRegister';
|
||||||
import { useUsernameAvailability } from '../../hooks/useUsernameAvailability';
|
import { useUsernameAvailability } from '../../hooks/useUsernameAvailability';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { authApi } from '@/services/api/auth';
|
import { authApi } from '@/services/api/auth';
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/logger';
|
||||||
import type { RegisterFormData } from '../../types';
|
import type { RegisterFormData } from '../../types';
|
||||||
|
|
@ -15,6 +16,7 @@ const initialFormData: RegisterFormData = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useRegisterPage() {
|
export function useRegisterPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
mutate: handleRegister,
|
mutate: handleRegister,
|
||||||
isPending: loading,
|
isPending: loading,
|
||||||
|
|
@ -30,36 +32,52 @@ export function useRegisterPage() {
|
||||||
const [showVerificationNotice, setShowVerificationNotice] = useState(false);
|
const [showVerificationNotice, setShowVerificationNotice] = useState(false);
|
||||||
const [resendLoading, setResendLoading] = useState(false);
|
const [resendLoading, setResendLoading] = useState(false);
|
||||||
const [resendSuccess, setResendSuccess] = useState(false);
|
const [resendSuccess, setResendSuccess] = useState(false);
|
||||||
|
const [resendError, setResendError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (success) setShowVerificationNotice(true);
|
if (success) {
|
||||||
}, [success]);
|
setShowVerificationNotice(true);
|
||||||
|
// Store email for the verify-email page's resend functionality
|
||||||
|
if (formData.email) {
|
||||||
|
localStorage.setItem('pendingVerificationEmail', formData.email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [success, formData.email]);
|
||||||
|
|
||||||
const validate = (): boolean => {
|
const validate = (): boolean => {
|
||||||
const newErrors: FormErrors = {};
|
const newErrors: FormErrors = {};
|
||||||
if (!formData.email) {
|
if (!formData.email) {
|
||||||
newErrors.email = 'Email requis';
|
newErrors.email = t('auth.register.errors.emailRequired');
|
||||||
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
|
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
|
||||||
newErrors.email = 'Email invalide';
|
newErrors.email = t('auth.register.errors.emailInvalid');
|
||||||
}
|
}
|
||||||
if (!formData.username) {
|
if (!formData.username) {
|
||||||
newErrors.username = "Nom d'utilisateur requis";
|
newErrors.username = t('auth.register.errors.usernameRequired');
|
||||||
} else if (formData.username.length < 3) {
|
} else if (formData.username.length < 3) {
|
||||||
newErrors.username = "Le nom d'utilisateur doit contenir au moins 3 caractères";
|
newErrors.username = t('auth.register.errors.usernameTooShort');
|
||||||
} else if (usernameAvailable === false) {
|
} else if (usernameAvailable === false) {
|
||||||
newErrors.username = "Ce nom d'utilisateur est déjà pris";
|
newErrors.username = t('auth.register.errors.usernameUnavailable');
|
||||||
}
|
}
|
||||||
if (!formData.password) {
|
if (!formData.password) {
|
||||||
newErrors.password = 'Mot de passe requis';
|
newErrors.password = t('auth.register.errors.passwordRequired');
|
||||||
} else if (formData.password.length < 12) {
|
} else if (formData.password.length < 12) {
|
||||||
newErrors.password = 'Le mot de passe doit contenir au moins 12 caractères';
|
newErrors.password = t('auth.register.errors.passwordTooShort');
|
||||||
|
} else {
|
||||||
|
const pwd = formData.password;
|
||||||
|
const hasCase = /[a-z]/.test(pwd) && /[A-Z]/.test(pwd);
|
||||||
|
const hasDigit = /\d/.test(pwd);
|
||||||
|
const hasSpecial = /[^a-zA-Z\d]/.test(pwd);
|
||||||
|
if (!hasCase || !hasDigit || !hasSpecial) {
|
||||||
|
newErrors.password = t('auth.register.errors.passwordWeak');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (formData.password !== formData.password_confirm) {
|
if (!formData.password_confirm) {
|
||||||
newErrors.password_confirm = 'Les mots de passe ne correspondent pas';
|
newErrors.password_confirm = t('auth.register.errors.confirmRequired');
|
||||||
|
} else if (formData.password !== formData.password_confirm) {
|
||||||
|
newErrors.password_confirm = t('auth.register.errors.passwordMismatch');
|
||||||
}
|
}
|
||||||
if (!acceptedTerms) {
|
if (!acceptedTerms) {
|
||||||
newErrors.terms =
|
newErrors.terms = t('auth.register.errors.termsRequired');
|
||||||
"Vous devez accepter les conditions d'utilisation et la politique de confidentialité";
|
|
||||||
}
|
}
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
return Object.keys(newErrors).length === 0;
|
return Object.keys(newErrors).length === 0;
|
||||||
|
|
@ -72,25 +90,34 @@ export function useRegisterPage() {
|
||||||
|
|
||||||
const handleBlur = (field: keyof RegisterFormData) => {
|
const handleBlur = (field: keyof RegisterFormData) => {
|
||||||
const value = formData[field];
|
const value = formData[field];
|
||||||
let error: string | undefined;
|
let fieldError: string | undefined;
|
||||||
switch (field) {
|
switch (field) {
|
||||||
case 'email':
|
case 'email':
|
||||||
if (!value) error = 'Email requis';
|
if (!value) fieldError = t('auth.register.errors.emailRequired');
|
||||||
else if (!/\S+@\S+\.\S+/.test(value)) error = 'Email invalide';
|
else if (!/\S+@\S+\.\S+/.test(value)) fieldError = t('auth.register.errors.emailInvalid');
|
||||||
break;
|
break;
|
||||||
case 'username':
|
case 'username':
|
||||||
if (!value) error = "Nom d'utilisateur requis";
|
if (!value) fieldError = t('auth.register.errors.usernameRequired');
|
||||||
else if (value.length < 3) error = "Le nom d'utilisateur doit contenir au moins 3 caractères";
|
else if (value.length < 3) fieldError = t('auth.register.errors.usernameTooShort');
|
||||||
break;
|
break;
|
||||||
case 'password':
|
case 'password':
|
||||||
if (!value) error = 'Mot de passe requis';
|
if (!value) fieldError = t('auth.register.errors.passwordRequired');
|
||||||
else if (value.length < 12) error = 'Le mot de passe doit contenir au moins 12 caractères';
|
else if (value.length < 12) fieldError = t('auth.register.errors.passwordTooShort');
|
||||||
|
else {
|
||||||
|
const hasCase = /[a-z]/.test(value) && /[A-Z]/.test(value);
|
||||||
|
const hasDigit = /\d/.test(value);
|
||||||
|
const hasSpecial = /[^a-zA-Z\d]/.test(value);
|
||||||
|
if (!hasCase || !hasDigit || !hasSpecial) {
|
||||||
|
fieldError = t('auth.register.errors.passwordWeak');
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'password_confirm':
|
case 'password_confirm':
|
||||||
if (formData.password !== value) error = 'Les mots de passe ne correspondent pas';
|
if (!value) fieldError = t('auth.register.errors.confirmRequired');
|
||||||
|
else if (formData.password !== value) fieldError = t('auth.register.errors.passwordMismatch');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
setErrors((prev) => ({ ...prev, [field]: error }));
|
setErrors((prev) => ({ ...prev, [field]: fieldError }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = (e: React.FormEvent) => {
|
const onSubmit = (e: React.FormEvent) => {
|
||||||
|
|
@ -102,10 +129,12 @@ export function useRegisterPage() {
|
||||||
try {
|
try {
|
||||||
setResendLoading(true);
|
setResendLoading(true);
|
||||||
setResendSuccess(false);
|
setResendSuccess(false);
|
||||||
|
setResendError(null);
|
||||||
await authApi.resendVerification({ email: formData.email });
|
await authApi.resendVerification({ email: formData.email });
|
||||||
setResendSuccess(true);
|
setResendSuccess(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error("Erreur lors du renvoi de l'email:", { error: err });
|
logger.error("Erreur lors du renvoi de l'email:", { error: err });
|
||||||
|
setResendError(t('auth.register.verification.resendError'));
|
||||||
} finally {
|
} finally {
|
||||||
setResendLoading(false);
|
setResendLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -124,6 +153,7 @@ export function useRegisterPage() {
|
||||||
error,
|
error,
|
||||||
resendLoading,
|
resendLoading,
|
||||||
resendSuccess,
|
resendSuccess,
|
||||||
|
resendError,
|
||||||
handleChange,
|
handleChange,
|
||||||
handleBlur,
|
handleBlur,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
|
|
||||||
|
|
@ -37,11 +37,17 @@ export function usePasswordReset() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setSuccess(false);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleRequestReset,
|
handleRequestReset,
|
||||||
handleReset,
|
handleReset,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
success,
|
success,
|
||||||
|
reset,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { register as registerService } from '@/services/api/auth';
|
|
||||||
import type { RegisterRequest } from '@/services/api/auth';
|
import type { RegisterRequest } from '@/services/api/auth';
|
||||||
|
|
||||||
export const useRegister = () => {
|
export const useRegister = () => {
|
||||||
|
|
@ -8,12 +7,7 @@ export const useRegister = () => {
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (userData: RegisterRequest) => {
|
mutationFn: async (userData: RegisterRequest) => {
|
||||||
// Appeler le service et mettre à jour le store
|
|
||||||
const response = await registerService(userData);
|
|
||||||
// Le store sera mis à jour automatiquement car registerService stocke déjà les tokens
|
|
||||||
// Mais on peut aussi appeler registerStore pour mettre à jour l'état
|
|
||||||
await registerStore(userData);
|
await registerStore(userData);
|
||||||
return response;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { within, userEvent } from '@storybook/test';
|
import { within, userEvent } from 'storybook/test';
|
||||||
import { ForgotPasswordPage } from './ForgotPasswordPage';
|
import { ForgotPasswordPage } from './ForgotPasswordPage';
|
||||||
import { withRouter, withQueryClient, withToast } from '../../../stories/decorators';
|
import { withRouter, withQueryClient, withToast } from '../../../stories/decorators';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,17 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
import { AuthLayout } from '../components/AuthLayout';
|
import { AuthLayout } from '../components/AuthLayout';
|
||||||
import { AuthInput } from '../components/AuthInput';
|
import { AuthInput } from '../components/AuthInput';
|
||||||
import { AuthButton } from '../components/AuthButton';
|
import { AuthButton } from '../components/AuthButton';
|
||||||
import { usePasswordReset } from '../hooks/usePasswordReset';
|
import { usePasswordReset } from '../hooks/usePasswordReset';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
import { forgotPasswordSchema } from '@/schemas/validation';
|
||||||
import type { ForgotPasswordFormData } from '../types';
|
import type { ForgotPasswordFormData } from '../types';
|
||||||
|
|
||||||
export function ForgotPasswordPage() {
|
export function ForgotPasswordPage() {
|
||||||
const { handleRequestReset, loading, error, success } = usePasswordReset();
|
const { t } = useTranslation();
|
||||||
|
const { handleRequestReset, loading, error, success, reset } = usePasswordReset();
|
||||||
const [formData, setFormData] = useState<ForgotPasswordFormData>({
|
const [formData, setFormData] = useState<ForgotPasswordFormData>({
|
||||||
email: '',
|
email: '',
|
||||||
});
|
});
|
||||||
|
|
@ -15,22 +19,33 @@ export function ForgotPasswordPage() {
|
||||||
Partial<Record<keyof ForgotPasswordFormData, string>>
|
Partial<Record<keyof ForgotPasswordFormData, string>>
|
||||||
>({});
|
>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = t('auth.forgotPassword.pageTitle');
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
const validate = (): boolean => {
|
const validate = (): boolean => {
|
||||||
const newErrors: Partial<Record<keyof ForgotPasswordFormData, string>> = {};
|
const result = forgotPasswordSchema.safeParse(formData);
|
||||||
|
if (!result.success) {
|
||||||
if (!formData.email) {
|
const newErrors: Partial<Record<keyof ForgotPasswordFormData, string>> = {};
|
||||||
newErrors.email = 'Email requis';
|
for (const issue of result.error.issues) {
|
||||||
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
|
const field = issue.path[0] as keyof ForgotPasswordFormData;
|
||||||
newErrors.email = 'Email invalide';
|
if (!newErrors[field]) {
|
||||||
|
if (field === 'email') {
|
||||||
|
newErrors[field] = !formData.email
|
||||||
|
? t('auth.forgotPassword.errors.emailRequired')
|
||||||
|
: t('auth.forgotPassword.errors.emailInvalid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setErrors(newErrors);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
setErrors({});
|
||||||
setErrors(newErrors);
|
return true;
|
||||||
return Object.keys(newErrors).length === 0;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = (field: keyof ForgotPasswordFormData, value: string) => {
|
const handleChange = (field: keyof ForgotPasswordFormData, value: string) => {
|
||||||
setFormData({ ...formData, [field]: value });
|
setFormData({ ...formData, [field]: value });
|
||||||
// Clear error for this field when user starts typing
|
|
||||||
if (errors[field]) {
|
if (errors[field]) {
|
||||||
setErrors({ ...errors, [field]: undefined });
|
setErrors({ ...errors, [field]: undefined });
|
||||||
}
|
}
|
||||||
|
|
@ -38,21 +53,20 @@ export function ForgotPasswordPage() {
|
||||||
|
|
||||||
const handleBlur = (field: keyof ForgotPasswordFormData) => {
|
const handleBlur = (field: keyof ForgotPasswordFormData) => {
|
||||||
const value = formData[field];
|
const value = formData[field];
|
||||||
let error: string | undefined;
|
let fieldError: string | undefined;
|
||||||
|
|
||||||
if (field === 'email') {
|
if (field === 'email') {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
error = 'Email requis';
|
fieldError = t('auth.forgotPassword.errors.emailRequired');
|
||||||
} else if (!/\S+@\S+\.\S+/.test(value)) {
|
} else {
|
||||||
error = 'Email invalide';
|
const result = forgotPasswordSchema.safeParse({ email: value });
|
||||||
|
if (!result.success) {
|
||||||
|
fieldError = t('auth.forgotPassword.errors.emailInvalid');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
setErrors((prev) => ({ ...prev, [field]: fieldError }));
|
||||||
setErrors({ ...errors, [field]: error });
|
|
||||||
} else {
|
|
||||||
setErrors({ ...errors, [field]: undefined });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = async (e: React.FormEvent) => {
|
const onSubmit = async (e: React.FormEvent) => {
|
||||||
|
|
@ -64,9 +78,9 @@ export function ForgotPasswordPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthLayout
|
<AuthLayout
|
||||||
title="Mot de passe oublié"
|
title={success ? t('auth.forgotPassword.successTitle') : t('auth.forgotPassword.title')}
|
||||||
subtitle="Entrez votre email pour recevoir un lien de réinitialisation"
|
subtitle={success ? undefined : t('auth.forgotPassword.subtitle')}
|
||||||
footerLinks={[{ label: 'Retour à la connexion', to: '/login' }]}
|
footerLinks={success ? [] : [{ label: t('auth.forgotPassword.backToLogin'), to: '/login' }]}
|
||||||
>
|
>
|
||||||
{success ? (
|
{success ? (
|
||||||
<div className="text-center space-y-4" role="status" aria-live="polite">
|
<div className="text-center space-y-4" role="status" aria-live="polite">
|
||||||
|
|
@ -75,27 +89,34 @@ export function ForgotPasswordPage() {
|
||||||
role="alert"
|
role="alert"
|
||||||
aria-live="assertive"
|
aria-live="assertive"
|
||||||
>
|
>
|
||||||
<p className="font-medium">Email envoyé !</p>
|
<p className="font-medium">{t('auth.forgotPassword.success')}</p>
|
||||||
<p className="text-sm mt-1">
|
<p className="text-sm mt-1">
|
||||||
Un lien de réinitialisation a été envoyé à {formData.email}
|
{t('auth.forgotPassword.successBody')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Veuillez vérifier votre boîte mail et cliquer sur le lien pour
|
{t('auth.forgotPassword.checkInbox')}
|
||||||
réinitialiser votre mot de passe.
|
|
||||||
</p>
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={reset}
|
||||||
|
className="text-primary hover:underline text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded"
|
||||||
|
>
|
||||||
|
{t('auth.forgotPassword.resendButton')}
|
||||||
|
</button>
|
||||||
<Link
|
<Link
|
||||||
to="/login"
|
to="/login"
|
||||||
className="text-primary hover:underline text-sm block focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded"
|
className="text-primary hover:underline text-sm block focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded"
|
||||||
>
|
>
|
||||||
Retour à la connexion
|
{t('auth.forgotPassword.backToLogin')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form
|
<form
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
aria-label="Formulaire de réinitialisation de mot de passe"
|
aria-label={t('auth.forgotPassword.formAriaLabel')}
|
||||||
|
data-testid="forgot-password-form"
|
||||||
>
|
>
|
||||||
{error && (
|
{error && (
|
||||||
<div
|
<div
|
||||||
|
|
@ -108,7 +129,7 @@ export function ForgotPasswordPage() {
|
||||||
)}
|
)}
|
||||||
<AuthInput
|
<AuthInput
|
||||||
type="email"
|
type="email"
|
||||||
label="Email"
|
label={t('auth.forgotPassword.email')}
|
||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={(e) => handleChange('email', e.target.value)}
|
onChange={(e) => handleChange('email', e.target.value)}
|
||||||
onBlur={() => handleBlur('email')}
|
onBlur={() => handleBlur('email')}
|
||||||
|
|
@ -116,8 +137,15 @@ export function ForgotPasswordPage() {
|
||||||
required
|
required
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
/>
|
/>
|
||||||
<AuthButton type="submit" loading={loading}>
|
<AuthButton type="submit" loading={loading} data-testid="forgot-password-submit">
|
||||||
Envoyer le lien de réinitialisation
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin inline" />
|
||||||
|
{t('auth.forgotPassword.sendButton')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t('auth.forgotPassword.sendButton')
|
||||||
|
)}
|
||||||
</AuthButton>
|
</AuthButton>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { within, userEvent } from '@storybook/test';
|
import { within, userEvent } from 'storybook/test';
|
||||||
import { LoginPage } from './LoginPage';
|
import { LoginPage } from './LoginPage';
|
||||||
import { withRouter, withQueryClient } from '../../../stories/decorators';
|
import { withRouter, withQueryClient } from '../../../stories/decorators';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,55 @@ import { LoginPage } from './LoginPage';
|
||||||
import { useAuthStore } from '@/features/auth/store/authStore';
|
import { useAuthStore } from '@/features/auth/store/authStore';
|
||||||
import { useLogin } from '../hooks/useLogin';
|
import { useLogin } from '../hooks/useLogin';
|
||||||
|
|
||||||
|
// Mock i18n
|
||||||
|
vi.mock('@/hooks/useTranslation', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, opts?: Record<string, string>) => {
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
'auth.login.title': 'Login',
|
||||||
|
'auth.login.subtitle': 'Sign in to your Veza account',
|
||||||
|
'auth.login.email': 'Email',
|
||||||
|
'auth.login.password': 'Password',
|
||||||
|
'auth.login.rememberMe': 'Remember me',
|
||||||
|
'auth.login.forgotPassword': 'Forgot password?',
|
||||||
|
'auth.login.loginButton': 'Sign in',
|
||||||
|
'auth.login.footerLink': "Don't have an account? Sign up",
|
||||||
|
'auth.login.orContinueWith': 'or continue with',
|
||||||
|
'auth.login.oauthProvider': `Sign in with ${opts?.provider ?? ''}`,
|
||||||
|
'auth.login.errors.invalidCredentials': 'Invalid email or password',
|
||||||
|
'auth.login.errors.emailNotVerified': 'Email not verified',
|
||||||
|
'auth.login.errors.connectionError': 'Connection error. Check your internet.',
|
||||||
|
'auth.login.errors.genericError': 'An error occurred. Please try again.',
|
||||||
|
'auth.login.errors.emailRequired': 'Email is required',
|
||||||
|
'auth.login.errors.emailInvalid': 'Invalid email format',
|
||||||
|
'auth.login.errors.passwordRequired': 'Password is required',
|
||||||
|
'auth.twoFactor.title': 'Two-factor authentication',
|
||||||
|
'auth.twoFactor.subtitle': 'Enter the code from your authenticator app',
|
||||||
|
'auth.twoFactor.backToSignIn': 'Back to sign in',
|
||||||
|
'auth.twoFactor.verificationCode': 'Verification Code',
|
||||||
|
'auth.twoFactor.enterCode': 'Enter the 6-digit code from your authenticator app to continue signing in.',
|
||||||
|
'auth.twoFactor.enterCodeError': 'Please enter a verification code',
|
||||||
|
'auth.twoFactor.lostAccess': 'Lost access?',
|
||||||
|
'auth.twoFactor.useBackupCode': 'Use a backup code',
|
||||||
|
'auth.twoFactor.useAuthenticator': 'Use authenticator code instead',
|
||||||
|
'auth.twoFactor.backupCode': 'Backup Code',
|
||||||
|
'auth.twoFactor.verify': 'Verify',
|
||||||
|
'auth.twoFactor.verifying': 'Verifying...',
|
||||||
|
'auth.twoFactor.cancel': 'Cancel',
|
||||||
|
'auth.layout.pageLabel': 'Authentication page',
|
||||||
|
'auth.layout.navLabel': 'Authentication navigation',
|
||||||
|
'common.loading': 'Loading...',
|
||||||
|
'common.loadingAria': 'Loading in progress',
|
||||||
|
};
|
||||||
|
return translations[key] ?? key;
|
||||||
|
},
|
||||||
|
language: 'en',
|
||||||
|
changeLanguage: vi.fn(),
|
||||||
|
isReady: true,
|
||||||
|
i18n: { isInitialized: true, changeLanguage: vi.fn() },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
vi.mock('@/features/auth/store/authStore', () => ({
|
vi.mock('@/features/auth/store/authStore', () => ({
|
||||||
useAuthStore: vi.fn(),
|
useAuthStore: vi.fn(),
|
||||||
|
|
@ -77,14 +126,14 @@ describe('LoginPage', () => {
|
||||||
it('should render login form', () => {
|
it('should render login form', () => {
|
||||||
render(<LoginPage />, { wrapper });
|
render(<LoginPage />, { wrapper });
|
||||||
|
|
||||||
expect(screen.getByText('Welcome Back')).toBeInTheDocument();
|
expect(screen.getByText('Login')).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText('Sign in to your account'),
|
screen.getByText('Sign in to your Veza account'),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText('Email')).toBeInTheDocument();
|
expect(screen.getByLabelText('Email')).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText('Password')).toBeInTheDocument();
|
expect(screen.getByLabelText('Password')).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('button', { name: 'Sign In' }),
|
screen.getByRole('button', { name: 'Sign in' }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -125,7 +174,7 @@ describe('LoginPage', () => {
|
||||||
render(<LoginPage />, { wrapper });
|
render(<LoginPage />, { wrapper });
|
||||||
|
|
||||||
const form = screen
|
const form = screen
|
||||||
.getByRole('button', { name: 'Sign In' })
|
.getByRole('button', { name: 'Sign in' })
|
||||||
.closest('form');
|
.closest('form');
|
||||||
expect(form).toBeInTheDocument();
|
expect(form).toBeInTheDocument();
|
||||||
|
|
||||||
|
|
@ -154,7 +203,7 @@ describe('LoginPage', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = screen
|
const form = screen
|
||||||
.getByRole('button', { name: 'Sign In' })
|
.getByRole('button', { name: 'Sign in' })
|
||||||
.closest('form');
|
.closest('form');
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
if (form) {
|
if (form) {
|
||||||
|
|
@ -175,7 +224,7 @@ describe('LoginPage', () => {
|
||||||
|
|
||||||
const emailInput = screen.getByLabelText('Email');
|
const emailInput = screen.getByLabelText('Email');
|
||||||
const passwordInput = screen.getByLabelText('Password');
|
const passwordInput = screen.getByLabelText('Password');
|
||||||
const submitButton = screen.getByRole('button', { name: 'Sign In' });
|
const submitButton = screen.getByRole('button', { name: 'Sign in' });
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await user.type(emailInput, 'test@example.com');
|
await user.type(emailInput, 'test@example.com');
|
||||||
|
|
@ -224,7 +273,7 @@ describe('LoginPage', () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await user.type(screen.getByLabelText('Email'), 'test@example.com');
|
await user.type(screen.getByLabelText('Email'), 'test@example.com');
|
||||||
await user.type(screen.getByLabelText('Password'), 'password123');
|
await user.type(screen.getByLabelText('Password'), 'password123');
|
||||||
await user.click(screen.getByRole('button', { name: 'Sign In' }));
|
await user.click(screen.getByRole('button', { name: 'Sign in' }));
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|
@ -257,7 +306,7 @@ describe('LoginPage', () => {
|
||||||
render(<LoginPage />, { wrapper });
|
render(<LoginPage />, { wrapper });
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByText('Incorrect email or password'),
|
screen.getByText('Invalid email or password'),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -287,9 +336,7 @@ describe('LoginPage', () => {
|
||||||
render(<LoginPage />, { wrapper });
|
render(<LoginPage />, { wrapper });
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(
|
screen.getByText('Email not verified'),
|
||||||
"Your email is not verified. Check your inbox.",
|
|
||||||
),
|
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -345,7 +392,7 @@ describe('LoginPage', () => {
|
||||||
render(<LoginPage />, { wrapper });
|
render(<LoginPage />, { wrapper });
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByText('Incorrect email or password'),
|
screen.getByText('Invalid email or password'),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -357,12 +404,10 @@ describe('LoginPage', () => {
|
||||||
|
|
||||||
render(<LoginPage />, { wrapper });
|
render(<LoginPage />, { wrapper });
|
||||||
|
|
||||||
// When loading, the button text changes to "Chargement..."
|
// When loading, the button is disabled and has aria-busy
|
||||||
expect(screen.getByText('Chargement...')).toBeInTheDocument();
|
const loadingButton = screen.getByRole('button', { name: 'Sign in' });
|
||||||
const loadingButton = screen.getByRole('button', {
|
|
||||||
name: 'Chargement en cours',
|
|
||||||
});
|
|
||||||
expect(loadingButton).toBeDisabled();
|
expect(loadingButton).toBeDisabled();
|
||||||
|
expect(loadingButton).toHaveAttribute('aria-busy', 'true');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update form data on input change', async () => {
|
it('should update form data on input change', async () => {
|
||||||
|
|
@ -452,34 +497,28 @@ describe('LoginPage', () => {
|
||||||
|
|
||||||
await waitFor(
|
await waitFor(
|
||||||
() => {
|
() => {
|
||||||
expect(screen.getByText('Format email invalide')).toBeInTheDocument();
|
expect(screen.getByText('Invalid email format')).toBeInTheDocument();
|
||||||
},
|
},
|
||||||
{ timeout: 1000 },
|
{ timeout: 1000 },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should validate password length on blur', async () => {
|
it('should not validate password length on login (only required check)', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<LoginPage />, { wrapper });
|
render(<LoginPage />, { wrapper });
|
||||||
|
|
||||||
const passwordInput = screen.getByLabelText('Password');
|
const passwordInput = screen.getByLabelText('Password');
|
||||||
|
|
||||||
// Type short password
|
// Type short password — should NOT show length error on login
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await user.type(passwordInput, '12345');
|
await user.type(passwordInput, '12345');
|
||||||
await user.tab(); // Trigger blur
|
await user.tab(); // Trigger blur
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(
|
// No password length validation error should appear
|
||||||
() => {
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
expect(
|
expect(screen.queryByText(/at least/i)).not.toBeInTheDocument();
|
||||||
screen.getByText(
|
expect(screen.queryByText(/au moins/i)).not.toBeInTheDocument();
|
||||||
'Le mot de passe doit contenir au moins 6 caractères',
|
|
||||||
),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
},
|
|
||||||
{ timeout: 1000 },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should clear error when user starts typing', async () => {
|
it('should clear error when user starts typing', async () => {
|
||||||
|
|
@ -498,7 +537,7 @@ describe('LoginPage', () => {
|
||||||
// Wait for validation error
|
// Wait for validation error
|
||||||
await waitFor(
|
await waitFor(
|
||||||
() => {
|
() => {
|
||||||
const errorText = screen.queryByText('Email requis');
|
const errorText = screen.queryByText('Email is required');
|
||||||
if (errorText) {
|
if (errorText) {
|
||||||
expect(errorText).toBeInTheDocument();
|
expect(errorText).toBeInTheDocument();
|
||||||
}
|
}
|
||||||
|
|
@ -513,7 +552,7 @@ describe('LoginPage', () => {
|
||||||
|
|
||||||
// Error should be cleared when typing
|
// Error should be cleared when typing
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const errorAfterTyping = screen.queryByText('Email requis');
|
const errorAfterTyping = screen.queryByText('Email is required');
|
||||||
// Error may or may not be cleared immediately, but the input should be updated
|
// Error may or may not be cleared immediately, but the input should be updated
|
||||||
expect(emailInput).toHaveValue('t');
|
expect(emailInput).toHaveValue('t');
|
||||||
});
|
});
|
||||||
|
|
@ -532,7 +571,7 @@ describe('LoginPage', () => {
|
||||||
|
|
||||||
await waitFor(
|
await waitFor(
|
||||||
() => {
|
() => {
|
||||||
expect(screen.getByText('Format email invalide')).toBeInTheDocument();
|
expect(screen.getByText('Invalid email format')).toBeInTheDocument();
|
||||||
},
|
},
|
||||||
{ timeout: 1000 },
|
{ timeout: 1000 },
|
||||||
);
|
);
|
||||||
|
|
@ -554,43 +593,34 @@ describe('LoginPage', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should validate password minimum length', async () => {
|
it('should only validate password as required (no length check on login)', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<LoginPage />, { wrapper });
|
render(<LoginPage />, { wrapper });
|
||||||
|
|
||||||
const passwordInput = screen.getByLabelText('Password');
|
const passwordInput = screen.getByLabelText('Password');
|
||||||
|
|
||||||
// Test short password
|
// Empty password should show required error
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await user.type(passwordInput, '12345');
|
await user.click(passwordInput);
|
||||||
await user.tab();
|
await user.tab();
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(
|
await waitFor(
|
||||||
() => {
|
() => {
|
||||||
expect(
|
expect(screen.getByText('Password is required')).toBeInTheDocument();
|
||||||
screen.getByText(
|
|
||||||
'Le mot de passe doit contenir au moins 6 caractères',
|
|
||||||
),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
},
|
},
|
||||||
{ timeout: 1000 },
|
{ timeout: 1000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Test valid password length
|
// Any non-empty password should pass (no length restriction on login)
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await user.clear(passwordInput);
|
await user.type(passwordInput, 'a');
|
||||||
await user.type(passwordInput, 'password123');
|
|
||||||
await user.tab();
|
await user.tab();
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(
|
await waitFor(
|
||||||
() => {
|
() => {
|
||||||
expect(
|
expect(screen.queryByText('Password is required')).not.toBeInTheDocument();
|
||||||
screen.queryByText(
|
|
||||||
'Le mot de passe doit contenir au moins 6 caractères',
|
|
||||||
),
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
},
|
},
|
||||||
{ timeout: 1000 },
|
{ timeout: 1000 },
|
||||||
);
|
);
|
||||||
|
|
@ -642,7 +672,7 @@ describe('LoginPage', () => {
|
||||||
const emailInput = screen.getByLabelText('Email');
|
const emailInput = screen.getByLabelText('Email');
|
||||||
const passwordInput = screen.getByLabelText('Password');
|
const passwordInput = screen.getByLabelText('Password');
|
||||||
const checkbox = screen.getByLabelText('Remember me');
|
const checkbox = screen.getByLabelText('Remember me');
|
||||||
const submitButton = screen.getByRole('button', { name: 'Sign In' });
|
const submitButton = screen.getByRole('button', { name: 'Sign in' });
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await user.type(emailInput, 'test@example.com');
|
await user.type(emailInput, 'test@example.com');
|
||||||
|
|
@ -689,7 +719,7 @@ describe('LoginPage', () => {
|
||||||
const checkbox = screen.getByLabelText(
|
const checkbox = screen.getByLabelText(
|
||||||
'Remember me',
|
'Remember me',
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
const submitButton = screen.getByRole('button', { name: 'Sign In' });
|
const submitButton = screen.getByRole('button', { name: 'Sign in' });
|
||||||
|
|
||||||
// Ensure checkbox is unchecked
|
// Ensure checkbox is unchecked
|
||||||
if (checkbox.checked) {
|
if (checkbox.checked) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { Navigate, Link, useNavigate } from 'react-router-dom';
|
import { Navigate, Link, useNavigate } from 'react-router-dom';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
|
@ -15,18 +15,19 @@ import type { ApiError } from '@/schemas/apiSchemas';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { AlertCircle } from 'lucide-react';
|
import { AlertCircle } from 'lucide-react';
|
||||||
import { AuthLayout } from '../components/AuthLayout';
|
import { AuthLayout } from '../components/AuthLayout';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
|
||||||
function getLoginErrorMessage(error: unknown): string {
|
function getLoginErrorMessage(error: unknown, t: (key: string) => string): string {
|
||||||
if (error == null) return '';
|
if (error == null) return '';
|
||||||
if (typeof error === 'object' && error !== null && 'message' in error && 'code' in error) {
|
if (typeof error === 'object' && error !== null && 'message' in error && 'code' in error) {
|
||||||
return formatApiErrorMessage(error as ApiError);
|
return formatApiErrorMessage(error as ApiError);
|
||||||
}
|
}
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
const msg = error.message?.toLowerCase() ?? '';
|
const msg = error.message?.toLowerCase() ?? '';
|
||||||
if (msg.includes('invalid credentials') || msg.includes('401')) return 'Incorrect email or password';
|
if (msg.includes('invalid credentials') || msg.includes('401')) return t('auth.login.errors.invalidCredentials');
|
||||||
if (msg.includes('email not verified')) return "Your email is not verified. Check your inbox.";
|
if (msg.includes('email not verified')) return t('auth.login.errors.emailNotVerified');
|
||||||
if (msg.includes('network')) return 'Connection error. Check your internet.';
|
if (msg.includes('network')) return t('auth.login.errors.connectionError');
|
||||||
return error.message || 'An error occurred. Please try again.';
|
return error.message || t('auth.login.errors.genericError');
|
||||||
}
|
}
|
||||||
return String(error);
|
return String(error);
|
||||||
}
|
}
|
||||||
|
|
@ -34,10 +35,17 @@ function getLoginErrorMessage(error: unknown): string {
|
||||||
type Pending2FA = { email: string; password: string; remember_me: boolean };
|
type Pending2FA = { email: string; password: string; remember_me: boolean };
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { isAuthenticated, isLoading, complete2FALogin, error: authStoreError } = useAuthStore();
|
const { isAuthenticated, isLoading, complete2FALogin } = useAuthStore();
|
||||||
const { mutate: handleLogin, isPending: loading, error } = useLogin();
|
const { mutate: handleLogin, isPending: loading, error: mutationError } = useLogin();
|
||||||
|
|
||||||
|
// BUG #11 fix: Use a ref to persist the error message across re-renders caused by auth state changes.
|
||||||
|
// The mutation error gets cleared when the component remounts, so we keep a stable copy.
|
||||||
|
const [displayError, setDisplayError] = useState<string | null>(null);
|
||||||
|
const formDataRef = useRef<LoginFormData>({ email: '', password: '' });
|
||||||
|
|
||||||
const [formData, setFormData] = useState<LoginFormData>({
|
const [formData, setFormData] = useState<LoginFormData>({
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
|
@ -50,14 +58,11 @@ export function LoginPage() {
|
||||||
const [pending2FA, setPending2FA] = useState<Pending2FA | null>(null);
|
const [pending2FA, setPending2FA] = useState<Pending2FA | null>(null);
|
||||||
const [loading2FA, setLoading2FA] = useState(false);
|
const [loading2FA, setLoading2FA] = useState(false);
|
||||||
const [error2FA, setError2FA] = useState<string | null>(null);
|
const [error2FA, setError2FA] = useState<string | null>(null);
|
||||||
const [loginError, setLoginError] = useState<string | null>(() => {
|
|
||||||
const stored = sessionStorage.getItem('login_error');
|
// Keep formDataRef in sync
|
||||||
if (stored) {
|
useEffect(() => {
|
||||||
sessionStorage.removeItem('login_error');
|
formDataRef.current = formData;
|
||||||
return stored;
|
}, [formData]);
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Charger l'email sauvegardé au montage
|
// Charger l'email sauvegardé au montage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -68,7 +73,14 @@ export function LoginPage() {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Rediriger si déjà connecté (user data géré par React Query, pas dans persist)
|
// Sync mutation error to display error
|
||||||
|
useEffect(() => {
|
||||||
|
if (mutationError) {
|
||||||
|
setDisplayError(getLoginErrorMessage(mutationError, t));
|
||||||
|
}
|
||||||
|
}, [mutationError, t]);
|
||||||
|
|
||||||
|
// Rediriger si déjà connecté
|
||||||
if (isAuthenticated && !isLoading && !loading) {
|
if (isAuthenticated && !isLoading && !loading) {
|
||||||
return <Navigate to="/dashboard" replace />;
|
return <Navigate to="/dashboard" replace />;
|
||||||
}
|
}
|
||||||
|
|
@ -79,13 +91,11 @@ export function LoginPage() {
|
||||||
): string | undefined => {
|
): string | undefined => {
|
||||||
switch (field) {
|
switch (field) {
|
||||||
case 'email':
|
case 'email':
|
||||||
if (!value) return 'Email requis';
|
if (!value) return t('auth.login.errors.emailRequired');
|
||||||
if (!/\S+@\S+\.\S+/.test(value)) return 'Format email invalide';
|
if (!/\S+@\S+\.\S+/.test(value)) return t('auth.login.errors.emailInvalid');
|
||||||
return undefined;
|
return undefined;
|
||||||
case 'password':
|
case 'password':
|
||||||
if (!value) return 'Mot de passe requis';
|
if (!value) return t('auth.login.errors.passwordRequired');
|
||||||
if (value.length < 6)
|
|
||||||
return 'Le mot de passe doit contenir au moins 6 caractères';
|
|
||||||
return undefined;
|
return undefined;
|
||||||
default:
|
default:
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
@ -111,8 +121,7 @@ export function LoginPage() {
|
||||||
|
|
||||||
const handleChange = (field: keyof LoginFormData, value: string) => {
|
const handleChange = (field: keyof LoginFormData, value: string) => {
|
||||||
setFormData({ ...formData, [field]: value });
|
setFormData({ ...formData, [field]: value });
|
||||||
if (loginError) setLoginError(null);
|
if (displayError) setDisplayError(null);
|
||||||
if (authStoreError) useAuthStore.getState().clearError?.();
|
|
||||||
if (errors[field]) {
|
if (errors[field]) {
|
||||||
setErrors({ ...errors, [field]: undefined });
|
setErrors({ ...errors, [field]: undefined });
|
||||||
}
|
}
|
||||||
|
|
@ -126,7 +135,7 @@ export function LoginPage() {
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem('rememberedEmail');
|
localStorage.removeItem('rememberedEmail');
|
||||||
}
|
}
|
||||||
setLoginError(null);
|
setDisplayError(null);
|
||||||
handleLogin(
|
handleLogin(
|
||||||
{ ...formData, remember_me },
|
{ ...formData, remember_me },
|
||||||
{
|
{
|
||||||
|
|
@ -144,9 +153,7 @@ export function LoginPage() {
|
||||||
navigate('/dashboard', { replace: true });
|
navigate('/dashboard', { replace: true });
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
const msg = getLoginErrorMessage(err);
|
setDisplayError(getLoginErrorMessage(err, t));
|
||||||
setLoginError(msg);
|
|
||||||
sessionStorage.setItem('login_error', msg);
|
|
||||||
logger.error('Login error', {
|
logger.error('Login error', {
|
||||||
error: err instanceof Error ? err.message : String(err),
|
error: err instanceof Error ? err.message : String(err),
|
||||||
stack: err instanceof Error ? err.stack : undefined,
|
stack: err instanceof Error ? err.stack : undefined,
|
||||||
|
|
@ -185,7 +192,7 @@ export function LoginPage() {
|
||||||
setPending2FA(null);
|
setPending2FA(null);
|
||||||
navigate('/dashboard', { replace: true });
|
navigate('/dashboard', { replace: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError2FA(getLoginErrorMessage(err));
|
setError2FA(getLoginErrorMessage(err, t));
|
||||||
logger.error('2FA login error', {
|
logger.error('2FA login error', {
|
||||||
error: err instanceof Error ? err.message : String(err),
|
error: err instanceof Error ? err.message : String(err),
|
||||||
});
|
});
|
||||||
|
|
@ -197,9 +204,9 @@ export function LoginPage() {
|
||||||
if (show2FA && pending2FA) {
|
if (show2FA && pending2FA) {
|
||||||
return (
|
return (
|
||||||
<AuthLayout
|
<AuthLayout
|
||||||
title="Two-factor authentication"
|
title={t('auth.twoFactor.title')}
|
||||||
subtitle="Enter the code from your authenticator app"
|
subtitle={t('auth.twoFactor.subtitle')}
|
||||||
footerLinks={[{ label: 'Back to sign in', to: '/login' }]}
|
footerLinks={[{ label: t('auth.twoFactor.backToSignIn'), to: '/login' }]}
|
||||||
>
|
>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{error2FA && (
|
{error2FA && (
|
||||||
|
|
@ -227,9 +234,9 @@ export function LoginPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthLayout
|
<AuthLayout
|
||||||
title="Welcome Back"
|
title={t('auth.login.title')}
|
||||||
subtitle="Sign in to your account"
|
subtitle={t('auth.login.subtitle')}
|
||||||
footerLinks={[{ label: "Don't have an account? Sign up", to: '/register' }]}
|
footerLinks={[{ label: t('auth.login.footerLink'), to: '/register' }]}
|
||||||
>
|
>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* OAuth providers + divider (hidden when no providers) */}
|
{/* OAuth providers + divider (hidden when no providers) */}
|
||||||
|
|
@ -255,27 +262,27 @@ export function LoginPage() {
|
||||||
<div className="w-full section-divider" />
|
<div className="w-full section-divider" />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex justify-center text-xs">
|
<div className="relative flex justify-center text-xs">
|
||||||
<span className="bg-card/80 backdrop-blur-sm px-4 text-muted-foreground/70">or continue with</span>
|
<span className="bg-card/80 backdrop-blur-sm px-4 text-muted-foreground/70">{t('auth.login.orContinueWith')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={onSubmit} className="space-y-4" data-testid="login-form">
|
<form onSubmit={onSubmit} className="space-y-4" data-testid="login-form">
|
||||||
{(loginError || error) && (
|
{displayError && (
|
||||||
<div
|
<div
|
||||||
className="bg-destructive/10 border border-destructive/30 text-destructive px-4 py-3 rounded-lg text-sm flex items-center gap-2 animate-in fade-in slide-in-from-top-1"
|
className="bg-destructive/10 border border-destructive/30 text-destructive px-4 py-3 rounded-lg text-sm flex items-center gap-2 animate-in fade-in slide-in-from-top-1"
|
||||||
role="alert"
|
role="alert"
|
||||||
>
|
>
|
||||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||||
<p>{loginError || getLoginErrorMessage(error)}</p>
|
<p>{displayError}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<AuthInput
|
<AuthInput
|
||||||
type="email"
|
type="email"
|
||||||
label="Email"
|
label={t('auth.login.email')}
|
||||||
value={formData.email}
|
value={formData.email}
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
onChange={(e) => handleChange('email', e.target.value)}
|
onChange={(e) => handleChange('email', e.target.value)}
|
||||||
|
|
@ -285,7 +292,7 @@ export function LoginPage() {
|
||||||
/>
|
/>
|
||||||
<AuthInput
|
<AuthInput
|
||||||
type="password"
|
type="password"
|
||||||
label="Password"
|
label={t('auth.login.password')}
|
||||||
value={formData.password}
|
value={formData.password}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
onChange={(e) => handleChange('password', e.target.value)}
|
onChange={(e) => handleChange('password', e.target.value)}
|
||||||
|
|
@ -302,13 +309,13 @@ export function LoginPage() {
|
||||||
id="remember_me"
|
id="remember_me"
|
||||||
checked={remember_me}
|
checked={remember_me}
|
||||||
onCheckedChange={(checked) => setRemember_me(checked)}
|
onCheckedChange={(checked) => setRemember_me(checked)}
|
||||||
label="Remember me"
|
label={t('auth.login.rememberMe')}
|
||||||
/>
|
/>
|
||||||
<Link
|
<Link
|
||||||
to="/forgot-password"
|
to="/forgot-password"
|
||||||
className="text-sm text-muted-foreground hover:text-foreground hover:underline underline-offset-4 transition-colors duration-[var(--duration-fast)] flex-shrink-0"
|
className="text-sm text-muted-foreground hover:text-foreground hover:underline underline-offset-4 transition-colors duration-[var(--duration-fast)] flex-shrink-0"
|
||||||
>
|
>
|
||||||
Forgot password?
|
{t('auth.login.forgotPassword')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -318,7 +325,7 @@ export function LoginPage() {
|
||||||
className="w-full bg-primary text-primary-foreground hover:brightness-110 shadow-md shadow-primary/20 transition-all duration-[var(--sumi-duration-normal)]"
|
className="w-full bg-primary text-primary-foreground hover:brightness-110 shadow-md shadow-primary/20 transition-all duration-[var(--sumi-duration-normal)]"
|
||||||
data-testid="login-submit"
|
data-testid="login-submit"
|
||||||
>
|
>
|
||||||
Sign In
|
{t('auth.login.loginButton')}
|
||||||
</AuthButton>
|
</AuthButton>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { within, userEvent } from '@storybook/test';
|
import { within, userEvent } from 'storybook/test';
|
||||||
import { ResetPasswordPage } from './ResetPasswordPage';
|
import { ResetPasswordPage } from './ResetPasswordPage';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,22 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
import { AuthLayout } from '../components/AuthLayout';
|
import { AuthLayout } from '../components/AuthLayout';
|
||||||
import { AuthInput } from '../components/AuthInput';
|
import { AuthInput } from '../components/AuthInput';
|
||||||
import { AuthButton } from '../components/AuthButton';
|
import { AuthButton } from '../components/AuthButton';
|
||||||
import { AuthErrorMessage } from '../components/AuthErrorMessage';
|
|
||||||
import { PasswordStrengthIndicator } from '../components/PasswordStrengthIndicator';
|
import { PasswordStrengthIndicator } from '../components/PasswordStrengthIndicator';
|
||||||
import { usePasswordReset } from '../hooks/usePasswordReset';
|
import { usePasswordReset } from '../hooks/usePasswordReset';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
import { resetPasswordSchema } from '@/schemas/validation';
|
||||||
import type { ResetPasswordFormData } from '../types';
|
import type { ResetPasswordFormData } from '../types';
|
||||||
|
|
||||||
export function ResetPasswordPage() {
|
export function ResetPasswordPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { handleReset, loading, error, success } = usePasswordReset();
|
const { handleReset, loading, error, success } = usePasswordReset();
|
||||||
const [token, setToken] = useState<string | null>(null);
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const [countdown, setCountdown] = useState(3);
|
||||||
const [formData, setFormData] = useState<ResetPasswordFormData>({
|
const [formData, setFormData] = useState<ResetPasswordFormData>({
|
||||||
token: '',
|
token: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
|
@ -22,7 +26,11 @@ export function ResetPasswordPage() {
|
||||||
Partial<Record<keyof ResetPasswordFormData, string>>
|
Partial<Record<keyof ResetPasswordFormData, string>>
|
||||||
>({});
|
>({});
|
||||||
|
|
||||||
// Extraire le token depuis l'URL
|
useEffect(() => {
|
||||||
|
document.title = t('auth.resetPassword.pageTitle');
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
// Extract token from URL
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const tokenParam = searchParams.get('token');
|
const tokenParam = searchParams.get('token');
|
||||||
if (tokenParam) {
|
if (tokenParam) {
|
||||||
|
|
@ -31,40 +39,52 @@ export function ResetPasswordPage() {
|
||||||
}
|
}
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
// Rediriger vers login après succès
|
// Redirect to login after success with countdown
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (success) {
|
if (success) {
|
||||||
const timer = setTimeout(() => {
|
setCountdown(3);
|
||||||
navigate('/login', { replace: true });
|
const interval = setInterval(() => {
|
||||||
}, 3000);
|
setCountdown((prev) => {
|
||||||
return () => clearTimeout(timer);
|
if (prev <= 1) {
|
||||||
|
clearInterval(interval);
|
||||||
|
navigate('/login', { replace: true });
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}, [success, navigate]);
|
}, [success, navigate]);
|
||||||
|
|
||||||
const validate = (): boolean => {
|
const validate = useCallback((): boolean => {
|
||||||
const newErrors: Partial<Record<keyof ResetPasswordFormData, string>> = {};
|
const result = resetPasswordSchema.safeParse(formData);
|
||||||
|
if (!result.success) {
|
||||||
if (!formData.password) {
|
const newErrors: Partial<Record<keyof ResetPasswordFormData, string>> = {};
|
||||||
newErrors.password = 'Mot de passe requis';
|
for (const issue of result.error.issues) {
|
||||||
} else if (formData.password.length < 8) {
|
const field = issue.path[0] as keyof ResetPasswordFormData;
|
||||||
newErrors.password =
|
if (!newErrors[field]) {
|
||||||
'Le mot de passe doit contenir au moins 8 caractères';
|
if (field === 'password') {
|
||||||
|
newErrors[field] = !formData.password
|
||||||
|
? t('auth.resetPassword.errors.passwordRequired')
|
||||||
|
: t('auth.resetPassword.errors.passwordTooShort');
|
||||||
|
} else if (field === 'confirmPassword') {
|
||||||
|
newErrors[field] = !formData.confirmPassword
|
||||||
|
? t('auth.resetPassword.errors.confirmRequired')
|
||||||
|
: t('auth.resetPassword.errors.passwordMismatch');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setErrors(newErrors);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
setErrors({});
|
||||||
if (!formData.confirmPassword) {
|
return true;
|
||||||
newErrors.confirmPassword = 'Confirmation du mot de passe requise';
|
}, [formData, t]);
|
||||||
} else if (formData.password !== formData.confirmPassword) {
|
|
||||||
newErrors.confirmPassword = 'Les mots de passe ne correspondent pas';
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrors(newErrors);
|
|
||||||
return Object.keys(newErrors).length === 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChange = (field: keyof ResetPasswordFormData, value: string) => {
|
const handleChange = (field: keyof ResetPasswordFormData, value: string) => {
|
||||||
setFormData({ ...formData, [field]: value });
|
setFormData({ ...formData, [field]: value });
|
||||||
// Clear error for this field when user starts typing
|
|
||||||
if (errors[field]) {
|
if (errors[field]) {
|
||||||
setErrors({ ...errors, [field]: undefined });
|
setErrors({ ...errors, [field]: undefined });
|
||||||
}
|
}
|
||||||
|
|
@ -72,48 +92,48 @@ export function ResetPasswordPage() {
|
||||||
|
|
||||||
const handleBlur = (field: keyof ResetPasswordFormData) => {
|
const handleBlur = (field: keyof ResetPasswordFormData) => {
|
||||||
const value = formData[field];
|
const value = formData[field];
|
||||||
let error: string | undefined;
|
let fieldError: string | undefined;
|
||||||
|
|
||||||
if (field === 'password') {
|
if (field === 'password') {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
error = 'Mot de passe requis';
|
fieldError = t('auth.resetPassword.errors.passwordRequired');
|
||||||
} else if (value.length < 8) {
|
} else {
|
||||||
error = 'Le mot de passe doit contenir au moins 8 caractères';
|
const result = resetPasswordSchema.safeParse({ ...formData, [field]: value });
|
||||||
|
if (!result.success) {
|
||||||
|
const pwIssue = result.error.issues.find((i) => i.path[0] === 'password');
|
||||||
|
if (pwIssue) {
|
||||||
|
fieldError = t('auth.resetPassword.errors.passwordTooShort');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (field === 'confirmPassword') {
|
} else if (field === 'confirmPassword') {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
error = 'Confirmation du mot de passe requise';
|
fieldError = t('auth.resetPassword.errors.confirmRequired');
|
||||||
} else if (formData.password !== value) {
|
} else if (formData.password !== value) {
|
||||||
error = 'Les mots de passe ne correspondent pas';
|
fieldError = t('auth.resetPassword.errors.passwordMismatch');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
setErrors((prev) => ({ ...prev, [field]: fieldError }));
|
||||||
setErrors({ ...errors, [field]: error });
|
|
||||||
} else {
|
|
||||||
setErrors({ ...errors, [field]: undefined });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = async (e: React.FormEvent) => {
|
const onSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!token) {
|
if (!token) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (validate()) {
|
if (validate()) {
|
||||||
await handleReset(formData);
|
await handleReset(formData);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Afficher un message si le token est invalide ou manquant
|
// State: invalid/missing token
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return (
|
return (
|
||||||
<AuthLayout
|
<AuthLayout
|
||||||
title="Lien de réinitialisation invalide"
|
title={t('auth.resetPassword.invalidToken.title')}
|
||||||
subtitle="Le lien de réinitialisation est invalide ou a expiré"
|
subtitle={t('auth.resetPassword.invalidToken.subtitle')}
|
||||||
footerLinks={[
|
footerLinks={[
|
||||||
{ label: 'Demander un nouveau lien', to: '/forgot-password' },
|
{ label: t('auth.resetPassword.requestNewLink'), to: '/forgot-password' },
|
||||||
{ label: 'Retour à la connexion', to: '/login' },
|
{ label: t('auth.resetPassword.backToLogin'), to: '/login' },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|
@ -122,10 +142,9 @@ export function ResetPasswordPage() {
|
||||||
aria-live="assertive"
|
aria-live="assertive"
|
||||||
>
|
>
|
||||||
<div className="bg-destructive/10 border border-destructive text-destructive px-4 py-4 rounded">
|
<div className="bg-destructive/10 border border-destructive text-destructive px-4 py-4 rounded">
|
||||||
<p className="font-medium">Lien invalide</p>
|
<p className="font-medium">{t('auth.resetPassword.invalidToken.heading')}</p>
|
||||||
<p className="text-sm mt-1">
|
<p className="text-sm mt-1">
|
||||||
Le lien de réinitialisation est invalide ou a expiré. Veuillez
|
{t('auth.resetPassword.invalidToken.body')}
|
||||||
demander un nouveau lien.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -133,52 +152,74 @@ export function ResetPasswordPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Afficher le message de succès
|
// State: success
|
||||||
if (success) {
|
if (success) {
|
||||||
return (
|
return (
|
||||||
<AuthLayout
|
<AuthLayout
|
||||||
title="Mot de passe réinitialisé"
|
title={t('auth.resetPassword.success.title')}
|
||||||
subtitle="Votre mot de passe a été modifié avec succès"
|
subtitle={t('auth.resetPassword.success.subtitle')}
|
||||||
footerLinks={[{ label: 'Retour à la connexion', to: '/login' }]}
|
footerLinks={[{ label: t('auth.resetPassword.backToLogin'), to: '/login' }]}
|
||||||
>
|
>
|
||||||
<div className="text-center space-y-4" role="status" aria-live="polite">
|
<div className="text-center space-y-4" role="status" aria-live="polite">
|
||||||
<div
|
<div
|
||||||
className="bg-success/10 border border-success text-success px-4 py-4 rounded"
|
className="bg-success/10 border border-success text-success px-4 py-4 rounded"
|
||||||
role="alert"
|
role="alert"
|
||||||
>
|
>
|
||||||
<p className="font-medium">Succès !</p>
|
<p className="font-medium">{t('auth.resetPassword.success.heading')}</p>
|
||||||
<p className="text-sm mt-1">
|
<p className="text-sm mt-1">
|
||||||
Votre mot de passe a été réinitialisé avec succès. Vous allez être
|
{t('auth.resetPassword.success.body')}
|
||||||
redirigé vers la page de connexion...
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('auth.resetPassword.success.redirecting', { seconds: countdown })}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</AuthLayout>
|
</AuthLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// State: form
|
||||||
return (
|
return (
|
||||||
<AuthLayout
|
<AuthLayout
|
||||||
title="Réinitialiser le mot de passe"
|
title={t('auth.resetPassword.title')}
|
||||||
subtitle="Entrez votre nouveau mot de passe"
|
subtitle={t('auth.resetPassword.subtitle')}
|
||||||
footerLinks={[{ label: 'Retour à la connexion', to: '/login' }]}
|
footerLinks={[{ label: t('auth.resetPassword.backToLogin'), to: '/login' }]}
|
||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
aria-label="Formulaire de réinitialisation de mot de passe"
|
aria-label={t('auth.resetPassword.formAriaLabel')}
|
||||||
|
data-testid="reset-password-form"
|
||||||
>
|
>
|
||||||
{error && <AuthErrorMessage message={error.message} />}
|
{/* Hidden username field for password managers */}
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="username"
|
||||||
|
autoComplete="username"
|
||||||
|
value=""
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="bg-destructive/10 border border-destructive text-destructive px-4 py-4 rounded"
|
||||||
|
role="alert"
|
||||||
|
aria-live="assertive"
|
||||||
|
>
|
||||||
|
{error.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<AuthInput
|
<AuthInput
|
||||||
type="password"
|
type="password"
|
||||||
label="Nouveau mot de passe"
|
label={t('auth.resetPassword.password')}
|
||||||
value={formData.password}
|
value={formData.password}
|
||||||
onChange={(e) => handleChange('password', e.target.value)}
|
onChange={(e) => handleChange('password', e.target.value)}
|
||||||
onBlur={() => handleBlur('password')}
|
onBlur={() => handleBlur('password')}
|
||||||
error={errors.password}
|
error={errors.password}
|
||||||
required
|
required
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
|
showPasswordToggle
|
||||||
/>
|
/>
|
||||||
<div aria-live="polite" aria-atomic="true">
|
<div aria-live="polite" aria-atomic="true">
|
||||||
<PasswordStrengthIndicator password={formData.password} />
|
<PasswordStrengthIndicator password={formData.password} />
|
||||||
|
|
@ -186,17 +227,25 @@ export function ResetPasswordPage() {
|
||||||
|
|
||||||
<AuthInput
|
<AuthInput
|
||||||
type="password"
|
type="password"
|
||||||
label="Confirmer le mot de passe"
|
label={t('auth.resetPassword.confirmPassword')}
|
||||||
value={formData.confirmPassword}
|
value={formData.confirmPassword}
|
||||||
onChange={(e) => handleChange('confirmPassword', e.target.value)}
|
onChange={(e) => handleChange('confirmPassword', e.target.value)}
|
||||||
onBlur={() => handleBlur('confirmPassword')}
|
onBlur={() => handleBlur('confirmPassword')}
|
||||||
error={errors.confirmPassword}
|
error={errors.confirmPassword}
|
||||||
required
|
required
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
|
showPasswordToggle
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AuthButton type="submit" loading={loading}>
|
<AuthButton type="submit" loading={loading} data-testid="reset-password-submit">
|
||||||
Réinitialiser le mot de passe
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin inline" />
|
||||||
|
{t('auth.resetPassword.submitButton')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t('auth.resetPassword.submitButton')
|
||||||
|
)}
|
||||||
</AuthButton>
|
</AuthButton>
|
||||||
</form>
|
</form>
|
||||||
</AuthLayout>
|
</AuthLayout>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,54 @@ vi.mock('@/services/api/auth', () => ({
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock i18n
|
||||||
|
vi.mock('@/hooks/useTranslation', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, opts?: Record<string, string>) => {
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
'auth.verifyEmail.title.verifying': 'Email Verification',
|
||||||
|
'auth.verifyEmail.title.success': 'Email Verified',
|
||||||
|
'auth.verifyEmail.title.error': 'Email Verification',
|
||||||
|
'auth.verifyEmail.subtitle.verifying': 'Verification in progress...',
|
||||||
|
'auth.verifyEmail.subtitle.success':
|
||||||
|
'Your email has been successfully verified',
|
||||||
|
'auth.verifyEmail.subtitle.error': 'An error occurred',
|
||||||
|
'auth.verifyEmail.message.verifying': 'Verifying your email...',
|
||||||
|
'auth.verifyEmail.message.success':
|
||||||
|
'Your email has been successfully verified!',
|
||||||
|
'auth.verifyEmail.message.invalidLink':
|
||||||
|
'Invalid or missing verification link',
|
||||||
|
'auth.verifyEmail.message.defaultError': 'Verification failed',
|
||||||
|
'auth.verifyEmail.message.resendSuccess':
|
||||||
|
'Verification email sent! Please check your inbox.',
|
||||||
|
'auth.verifyEmail.message.emailNotFound':
|
||||||
|
'Email not found. Please register again or contact support.',
|
||||||
|
'auth.verifyEmail.message.resendError': 'Failed to send the email',
|
||||||
|
'auth.verifyEmail.button.retry': 'Retry',
|
||||||
|
'auth.verifyEmail.button.resend': 'Resend verification email',
|
||||||
|
'auth.verifyEmail.button.resendCooldown': `Resend in ${opts?.seconds ?? ''}s`,
|
||||||
|
'auth.verifyEmail.button.resendCooldownAriaLabel': `Resend verification email in ${opts?.seconds ?? ''} seconds`,
|
||||||
|
'auth.verifyEmail.button.resendAriaLabel':
|
||||||
|
'Resend verification email',
|
||||||
|
'auth.verifyEmail.success.title': 'Success!',
|
||||||
|
'auth.verifyEmail.success.redirecting':
|
||||||
|
'You will be redirected to the login page...',
|
||||||
|
'auth.verifyEmail.error.title': 'Error',
|
||||||
|
'auth.verifyEmail.srOnly.verifying':
|
||||||
|
'Verifying your email, please wait',
|
||||||
|
'auth.verifyEmail.footer.backToLogin': 'Back to login',
|
||||||
|
'auth.layout.pageLabel': 'Authentication page',
|
||||||
|
'auth.layout.navLabel': 'Authentication navigation',
|
||||||
|
};
|
||||||
|
return translations[key] ?? key;
|
||||||
|
},
|
||||||
|
language: 'en',
|
||||||
|
changeLanguage: vi.fn(),
|
||||||
|
isReady: true,
|
||||||
|
i18n: { isInitialized: true, changeLanguage: vi.fn() },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
const mockNavigate = vi.fn();
|
const mockNavigate = vi.fn();
|
||||||
vi.mock('react-router-dom', async () => {
|
vi.mock('react-router-dom', async () => {
|
||||||
const actual = await vi.importActual('react-router-dom');
|
const actual = await vi.importActual('react-router-dom');
|
||||||
|
|
@ -37,6 +85,15 @@ describe('VerifyEmailPage', () => {
|
||||||
vi.clearAllTimers();
|
vi.clearAllTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should display error message when token is missing', () => {
|
||||||
|
render(<VerifyEmailPage />, { wrapper });
|
||||||
|
|
||||||
|
expect(screen.getByTestId('verify-email-error')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('verify-email-message')).toHaveTextContent(
|
||||||
|
'Invalid or missing verification link',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should render verifying state when token is present', async () => {
|
it('should render verifying state when token is present', async () => {
|
||||||
vi.mocked(authApi.verifyEmail).mockResolvedValue({
|
vi.mocked(authApi.verifyEmail).mockResolvedValue({
|
||||||
message: 'Email verified',
|
message: 'Email verified',
|
||||||
|
|
@ -50,20 +107,8 @@ describe('VerifyEmailPage', () => {
|
||||||
|
|
||||||
render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
|
render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
|
||||||
|
|
||||||
// Should show verifying state initially
|
const heading = document.querySelector('h1');
|
||||||
await waitFor(() => {
|
expect(heading).toBeInTheDocument();
|
||||||
const heading = document.querySelector('h1');
|
|
||||||
expect(heading).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should display error message when token is missing', () => {
|
|
||||||
render(<VerifyEmailPage />, { wrapper });
|
|
||||||
|
|
||||||
expect(screen.getByText("Vérification de l'email")).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getByText('Lien de vérification invalide ou manquant'),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should extract token from URL and verify email', async () => {
|
it('should extract token from URL and verify email', async () => {
|
||||||
|
|
@ -79,7 +124,6 @@ describe('VerifyEmailPage', () => {
|
||||||
|
|
||||||
render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
|
render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
|
||||||
|
|
||||||
// Wait for verification to complete
|
|
||||||
await waitFor(
|
await waitFor(
|
||||||
() => {
|
() => {
|
||||||
expect(authApi.verifyEmail).toHaveBeenCalledWith({ token: 'abc123' });
|
expect(authApi.verifyEmail).toHaveBeenCalledWith({ token: 'abc123' });
|
||||||
|
|
@ -101,17 +145,16 @@ describe('VerifyEmailPage', () => {
|
||||||
|
|
||||||
render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
|
render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
|
||||||
|
|
||||||
// Wait for success message
|
|
||||||
await waitFor(
|
await waitFor(
|
||||||
() => {
|
() => {
|
||||||
expect(screen.getByText('Email vérifié')).toBeInTheDocument();
|
expect(screen.getByTestId('verify-email-success')).toBeInTheDocument();
|
||||||
},
|
},
|
||||||
{ timeout: 2000 },
|
{ timeout: 2000 },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display error message when verification fails', async () => {
|
it('should display error message when verification fails', async () => {
|
||||||
const error = { message: 'Token invalide ou expiré' };
|
const error = { message: 'Token invalid or expired' };
|
||||||
vi.mocked(authApi.verifyEmail).mockRejectedValue(error);
|
vi.mocked(authApi.verifyEmail).mockRejectedValue(error);
|
||||||
|
|
||||||
const wrapperWithToken = ({ children }: { children: React.ReactNode }) => (
|
const wrapperWithToken = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
|
@ -122,12 +165,12 @@ describe('VerifyEmailPage', () => {
|
||||||
|
|
||||||
render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
|
render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
|
||||||
|
|
||||||
// Wait for error message
|
|
||||||
await waitFor(
|
await waitFor(
|
||||||
() => {
|
() => {
|
||||||
expect(
|
expect(screen.getByTestId('verify-email-error')).toBeInTheDocument();
|
||||||
screen.getByText('Token invalide ou expiré'),
|
expect(screen.getByTestId('verify-email-message')).toHaveTextContent(
|
||||||
).toBeInTheDocument();
|
'Token invalid or expired',
|
||||||
|
);
|
||||||
},
|
},
|
||||||
{ timeout: 3000 },
|
{ timeout: 3000 },
|
||||||
);
|
);
|
||||||
|
|
@ -146,12 +189,10 @@ describe('VerifyEmailPage', () => {
|
||||||
|
|
||||||
render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
|
render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
|
||||||
|
|
||||||
// Wait for success
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Email vérifié')).toBeInTheDocument();
|
expect(screen.getByTestId('verify-email-success')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for the redirect timer (3 seconds)
|
|
||||||
await waitFor(
|
await waitFor(
|
||||||
() => {
|
() => {
|
||||||
expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true });
|
expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true });
|
||||||
|
|
@ -160,7 +201,7 @@ describe('VerifyEmailPage', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display retry button when verification fails', async () => {
|
it('should display retry button when verification fails with token', async () => {
|
||||||
const error = { message: 'Verification failed' };
|
const error = { message: 'Verification failed' };
|
||||||
vi.mocked(authApi.verifyEmail).mockRejectedValue(error);
|
vi.mocked(authApi.verifyEmail).mockRejectedValue(error);
|
||||||
|
|
||||||
|
|
@ -172,10 +213,9 @@ describe('VerifyEmailPage', () => {
|
||||||
|
|
||||||
render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
|
render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
|
||||||
|
|
||||||
// Wait for error state
|
|
||||||
await waitFor(
|
await waitFor(
|
||||||
() => {
|
() => {
|
||||||
expect(screen.getByText('Réessayer')).toBeInTheDocument();
|
expect(screen.getByTestId('verify-email-retry')).toBeInTheDocument();
|
||||||
},
|
},
|
||||||
{ timeout: 3000 },
|
{ timeout: 3000 },
|
||||||
);
|
);
|
||||||
|
|
@ -196,24 +236,21 @@ describe('VerifyEmailPage', () => {
|
||||||
|
|
||||||
render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
|
render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
|
||||||
|
|
||||||
// Wait for error state
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Réessayer')).toBeInTheDocument();
|
expect(screen.getByTestId('verify-email-retry')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Click retry button
|
const retryButton = screen.getByTestId('verify-email-retry');
|
||||||
const retryButton = screen.getByText('Réessayer');
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await user.click(retryButton);
|
await user.click(retryButton);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Should call verifyEmail again
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(authApi.verifyEmail).toHaveBeenCalledTimes(2);
|
expect(authApi.verifyEmail).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display resend email button', async () => {
|
it('should display resend email button in error state', async () => {
|
||||||
const error = { message: 'Verification failed' };
|
const error = { message: 'Verification failed' };
|
||||||
vi.mocked(authApi.verifyEmail).mockRejectedValue(error);
|
vi.mocked(authApi.verifyEmail).mockRejectedValue(error);
|
||||||
|
|
||||||
|
|
@ -225,12 +262,9 @@ describe('VerifyEmailPage', () => {
|
||||||
|
|
||||||
render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
|
render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
|
||||||
|
|
||||||
// Wait for error state
|
|
||||||
await waitFor(
|
await waitFor(
|
||||||
() => {
|
() => {
|
||||||
expect(
|
expect(screen.getByTestId('verify-email-resend')).toBeInTheDocument();
|
||||||
screen.getByText(/Renvoyer l'email de vérification/),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
},
|
},
|
||||||
{ timeout: 3000 },
|
{ timeout: 3000 },
|
||||||
);
|
);
|
||||||
|
|
@ -254,23 +288,18 @@ describe('VerifyEmailPage', () => {
|
||||||
|
|
||||||
render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
|
render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
|
||||||
|
|
||||||
// Wait for error state
|
|
||||||
await waitFor(
|
await waitFor(
|
||||||
() => {
|
() => {
|
||||||
expect(
|
expect(screen.getByTestId('verify-email-resend')).toBeInTheDocument();
|
||||||
screen.getByText(/Renvoyer l'email de vérification/),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
},
|
},
|
||||||
{ timeout: 3000 },
|
{ timeout: 3000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Click resend button
|
const resendButton = screen.getByTestId('verify-email-resend');
|
||||||
const resendButton = screen.getByText(/Renvoyer l'email de vérification/);
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await user.click(resendButton);
|
await user.click(resendButton);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Should call resendVerification
|
|
||||||
await waitFor(
|
await waitFor(
|
||||||
() => {
|
() => {
|
||||||
expect(authApi.resendVerification).toHaveBeenCalledWith({
|
expect(authApi.resendVerification).toHaveBeenCalledWith({
|
||||||
|
|
@ -281,7 +310,7 @@ describe('VerifyEmailPage', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set cooldown after resending email', async () => {
|
it('should show success styled container after successful resend', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const error = { message: 'Verification failed' };
|
const error = { message: 'Verification failed' };
|
||||||
vi.mocked(authApi.verifyEmail).mockRejectedValue(error);
|
vi.mocked(authApi.verifyEmail).mockRejectedValue(error);
|
||||||
|
|
@ -299,39 +328,27 @@ describe('VerifyEmailPage', () => {
|
||||||
|
|
||||||
render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
|
render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
|
||||||
|
|
||||||
// Wait for error state
|
await waitFor(() => {
|
||||||
await waitFor(
|
expect(screen.getByTestId('verify-email-resend')).toBeInTheDocument();
|
||||||
() => {
|
});
|
||||||
expect(
|
|
||||||
screen.getByText(/Renvoyer l'email de vérification/),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
},
|
|
||||||
{ timeout: 2000 },
|
|
||||||
);
|
|
||||||
|
|
||||||
// Click resend button
|
const resendButton = screen.getByTestId('verify-email-resend');
|
||||||
const resendButton = screen.getByText(/Renvoyer l'email de vérification/);
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await user.click(resendButton);
|
await user.click(resendButton);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verify that resendVerification was called
|
await waitFor(() => {
|
||||||
await waitFor(
|
expect(
|
||||||
() => {
|
screen.getByTestId('verify-email-resend-success'),
|
||||||
expect(authApi.resendVerification).toHaveBeenCalledWith({
|
).toBeInTheDocument();
|
||||||
email: 'test@example.com',
|
});
|
||||||
});
|
|
||||||
},
|
|
||||||
{ timeout: 2000 },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle resend when email is not found', async () => {
|
it('should handle resend when email is not found in localStorage', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const error = { message: 'Verification failed' };
|
const error = { message: 'Verification failed' };
|
||||||
vi.mocked(authApi.verifyEmail).mockRejectedValue(error);
|
vi.mocked(authApi.verifyEmail).mockRejectedValue(error);
|
||||||
|
|
||||||
// No email in localStorage
|
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
|
|
||||||
const wrapperWithToken = ({ children }: { children: React.ReactNode }) => (
|
const wrapperWithToken = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
|
@ -342,32 +359,57 @@ describe('VerifyEmailPage', () => {
|
||||||
|
|
||||||
render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
|
render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
|
||||||
|
|
||||||
// Wait for error state
|
await waitFor(() => {
|
||||||
await waitFor(
|
expect(screen.getByTestId('verify-email-resend')).toBeInTheDocument();
|
||||||
() => {
|
});
|
||||||
expect(
|
|
||||||
screen.getByText(/Renvoyer l'email de vérification/),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
},
|
|
||||||
{ timeout: 2000 },
|
|
||||||
);
|
|
||||||
|
|
||||||
// Click resend button
|
const resendButton = screen.getByTestId('verify-email-resend');
|
||||||
const resendButton = screen.getByText(/Renvoyer l'email de vérification/);
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await user.click(resendButton);
|
await user.click(resendButton);
|
||||||
});
|
});
|
||||||
|
|
||||||
// resendVerification should NOT be called since email is missing
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
expect(authApi.resendVerification).not.toHaveBeenCalled();
|
expect(authApi.resendVerification).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByTestId('verify-email-message')).toHaveTextContent(
|
||||||
|
'Email not found',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display footer links', () => {
|
it('should display footer link to login page', () => {
|
||||||
render(<VerifyEmailPage />, { wrapper });
|
render(<VerifyEmailPage />, { wrapper });
|
||||||
|
|
||||||
const loginLink = screen.getByText('Retour à la connexion');
|
const loginLink = screen.getByText('Back to login');
|
||||||
expect(loginLink).toBeInTheDocument();
|
expect(loginLink).toBeInTheDocument();
|
||||||
expect(loginLink.closest('a')).toHaveAttribute('href', '/login');
|
expect(loginLink.closest('a')).toHaveAttribute('href', '/login');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not show retry button when no token is present', () => {
|
||||||
|
render(<VerifyEmailPage />, { wrapper });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId('verify-email-retry'),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('verify-email-resend')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clean pendingVerificationEmail from localStorage after success', async () => {
|
||||||
|
vi.mocked(authApi.verifyEmail).mockResolvedValue({
|
||||||
|
message: 'Email verified',
|
||||||
|
});
|
||||||
|
localStorage.setItem('pendingVerificationEmail', 'test@example.com');
|
||||||
|
|
||||||
|
const wrapperWithToken = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<MemoryRouter initialEntries={['/verify-email?token=test-token']}>
|
||||||
|
{children}
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<VerifyEmailPage />, { wrapper: wrapperWithToken });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('verify-email-success')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(localStorage.getItem('pendingVerificationEmail')).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,74 +3,72 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { AuthLayout } from '../components/AuthLayout';
|
import { AuthLayout } from '../components/AuthLayout';
|
||||||
import { AuthButton } from '../components/AuthButton';
|
import { AuthButton } from '../components/AuthButton';
|
||||||
import { authApi } from '@/services/api/auth';
|
import { authApi } from '@/services/api/auth';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import type { ApiError } from '@/schemas/apiSchemas';
|
import type { ApiError } from '@/schemas/apiSchemas';
|
||||||
|
|
||||||
export function VerifyEmailPage() {
|
export function VerifyEmailPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [status, setStatus] = useState<'verifying' | 'success' | 'error'>(
|
const [status, setStatus] = useState<'verifying' | 'success' | 'error'>(
|
||||||
'verifying',
|
'verifying',
|
||||||
);
|
);
|
||||||
const [message, setMessage] = useState(
|
const [message, setMessage] = useState('');
|
||||||
'Vérification de votre email en cours...',
|
|
||||||
);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [resendLoading, setResendLoading] = useState(false);
|
const [resendLoading, setResendLoading] = useState(false);
|
||||||
const [resendCooldown, setResendCooldown] = useState(0);
|
const [resendCooldown, setResendCooldown] = useState(0);
|
||||||
const cooldownIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
const [resendSuccess, setResendSuccess] = useState(false);
|
||||||
const [token, setToken] = useState<string | null>(null);
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const hasVerifiedRef = useRef(false);
|
||||||
|
|
||||||
// Extraire le token depuis l'URL
|
// Extract token from URL and verify automatically (guarded against Strict Mode double-invocation)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const tokenParam = searchParams.get('token');
|
const tokenParam = searchParams.get('token');
|
||||||
if (tokenParam) {
|
if (tokenParam && !hasVerifiedRef.current) {
|
||||||
|
hasVerifiedRef.current = true;
|
||||||
setToken(tokenParam);
|
setToken(tokenParam);
|
||||||
// Vérifier l'email automatiquement si le token est présent
|
|
||||||
handleVerifyEmail(tokenParam);
|
// Clean token from URL to prevent leakage via Referer header / browser history
|
||||||
} else {
|
window.history.replaceState({}, '', '/verify-email');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setStatus('verifying');
|
||||||
|
setMessage(t('auth.verifyEmail.message.verifying'));
|
||||||
|
|
||||||
|
await authApi.verifyEmail({ token: tokenParam });
|
||||||
|
|
||||||
|
setStatus('success');
|
||||||
|
setMessage(t('auth.verifyEmail.message.success'));
|
||||||
|
localStorage.removeItem('pendingVerificationEmail');
|
||||||
|
} catch (error) {
|
||||||
|
setStatus('error');
|
||||||
|
const apiError = error as ApiError;
|
||||||
|
setMessage(
|
||||||
|
apiError.message || t('auth.verifyEmail.message.defaultError'),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
} else if (!tokenParam && !hasVerifiedRef.current) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setMessage('Lien de vérification invalide ou manquant');
|
setMessage(t('auth.verifyEmail.message.invalidLink'));
|
||||||
}
|
}
|
||||||
}, [searchParams]);
|
}, [searchParams, t]);
|
||||||
|
|
||||||
// Cleanup cooldown interval on unmount
|
// Cooldown timer (simplified: one setTimeout per tick instead of setInterval churn)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
if (resendCooldown <= 0) return;
|
||||||
if (cooldownIntervalRef.current) {
|
const timer = setTimeout(
|
||||||
clearInterval(cooldownIntervalRef.current);
|
() => setResendCooldown((c) => c - 1),
|
||||||
}
|
1000,
|
||||||
};
|
);
|
||||||
}, []);
|
return () => clearTimeout(timer);
|
||||||
|
|
||||||
// Gérer le cooldown pour le renvoi d'email
|
|
||||||
useEffect(() => {
|
|
||||||
if (resendCooldown > 0) {
|
|
||||||
cooldownIntervalRef.current = setInterval(() => {
|
|
||||||
setResendCooldown((prev) => {
|
|
||||||
if (prev <= 1) {
|
|
||||||
if (cooldownIntervalRef.current) {
|
|
||||||
clearInterval(cooldownIntervalRef.current);
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return prev - 1;
|
|
||||||
});
|
|
||||||
}, 1000);
|
|
||||||
} else {
|
|
||||||
if (cooldownIntervalRef.current) {
|
|
||||||
clearInterval(cooldownIntervalRef.current);
|
|
||||||
cooldownIntervalRef.current = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (cooldownIntervalRef.current) {
|
|
||||||
clearInterval(cooldownIntervalRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [resendCooldown]);
|
}, [resendCooldown]);
|
||||||
|
|
||||||
// Rediriger vers login après succès
|
// Redirect to login after successful verification
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === 'success') {
|
if (status === 'success') {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
|
|
@ -81,102 +79,126 @@ export function VerifyEmailPage() {
|
||||||
return undefined;
|
return undefined;
|
||||||
}, [status, navigate]);
|
}, [status, navigate]);
|
||||||
|
|
||||||
const handleVerifyEmail = async (emailToken: string) => {
|
// Set document.title based on current status
|
||||||
|
useEffect(() => {
|
||||||
|
const titles: Record<typeof status, string> = {
|
||||||
|
verifying: t('auth.verifyEmail.title.verifying'),
|
||||||
|
success: t('auth.verifyEmail.title.success'),
|
||||||
|
error: t('auth.verifyEmail.title.error'),
|
||||||
|
};
|
||||||
|
document.title = `${titles[status]} - Veza`;
|
||||||
|
}, [status, t]);
|
||||||
|
|
||||||
|
const handleRetryVerification = async () => {
|
||||||
|
if (!token || loading) return;
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setStatus('verifying');
|
setStatus('verifying');
|
||||||
setMessage('Vérification de votre email en cours...');
|
setMessage(t('auth.verifyEmail.message.verifying'));
|
||||||
|
setResendSuccess(false);
|
||||||
|
|
||||||
await authApi.verifyEmail({ token: emailToken });
|
await authApi.verifyEmail({ token });
|
||||||
|
|
||||||
setStatus('success');
|
setStatus('success');
|
||||||
setMessage('Votre email a été vérifié avec succès !');
|
setMessage(t('auth.verifyEmail.message.success'));
|
||||||
|
localStorage.removeItem('pendingVerificationEmail');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
const apiError = error as ApiError;
|
const apiError = error as ApiError;
|
||||||
setMessage(apiError.message || 'La vérification a échoué');
|
setMessage(
|
||||||
|
apiError.message || t('auth.verifyEmail.message.defaultError'),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResendVerificationEmail = async () => {
|
const handleResendVerificationEmail = async () => {
|
||||||
if (resendCooldown > 0 || resendLoading) {
|
if (resendCooldown > 0 || resendLoading) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setResendLoading(true);
|
setResendLoading(true);
|
||||||
|
setResendSuccess(false);
|
||||||
|
|
||||||
// Récupérer l'email depuis localStorage (stocké lors de l'inscription)
|
|
||||||
const email = localStorage.getItem('pendingVerificationEmail');
|
const email = localStorage.getItem('pendingVerificationEmail');
|
||||||
if (!email) {
|
if (!email) {
|
||||||
setMessage(
|
setMessage(t('auth.verifyEmail.message.emailNotFound'));
|
||||||
'Email non trouvé. Veuillez vous réinscrire ou contacter le support.',
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await authApi.resendVerification({ email });
|
await authApi.resendVerification({ email });
|
||||||
|
|
||||||
// Définir cooldown de 60 secondes
|
|
||||||
setResendCooldown(60);
|
setResendCooldown(60);
|
||||||
|
setResendSuccess(true);
|
||||||
// Afficher message de confirmation
|
setMessage(t('auth.verifyEmail.message.resendSuccess'));
|
||||||
setMessage(
|
|
||||||
'Email de vérification envoyé ! Veuillez vérifier votre boîte mail.',
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const apiError = error as ApiError;
|
const apiError = error as ApiError;
|
||||||
setMessage(apiError.message || "Échec de l'envoi de l'email");
|
setResendSuccess(false);
|
||||||
|
setMessage(
|
||||||
|
apiError.message || t('auth.verifyEmail.message.resendError'),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setResendLoading(false);
|
setResendLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Afficher le statut de vérification
|
const footerLinks = [
|
||||||
|
{ label: t('auth.verifyEmail.footer.backToLogin'), to: '/login' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Verifying state
|
||||||
if (status === 'verifying') {
|
if (status === 'verifying') {
|
||||||
return (
|
return (
|
||||||
<AuthLayout
|
<AuthLayout
|
||||||
title="Vérification de l'email"
|
title={t('auth.verifyEmail.title.verifying')}
|
||||||
subtitle="Vérification en cours..."
|
subtitle={t('auth.verifyEmail.subtitle.verifying')}
|
||||||
footerLinks={[{ label: 'Retour à la connexion', to: '/login' }]}
|
footerLinks={footerLinks}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="text-center space-y-4"
|
className="text-center space-y-4"
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
aria-busy="true"
|
aria-busy="true"
|
||||||
|
data-testid="verify-email-spinner"
|
||||||
>
|
>
|
||||||
<div className="flex justify-center" aria-hidden="true">
|
<div className="flex justify-center" aria-hidden="true">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground">{message}</p>
|
<p className="text-muted-foreground" data-testid="verify-email-message">
|
||||||
|
{message || t('auth.verifyEmail.message.verifying')}
|
||||||
|
</p>
|
||||||
<span className="sr-only">
|
<span className="sr-only">
|
||||||
Vérification de votre email en cours, veuillez patienter
|
{t('auth.verifyEmail.srOnly.verifying')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</AuthLayout>
|
</AuthLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Afficher le message de succès
|
// Success state
|
||||||
if (status === 'success') {
|
if (status === 'success') {
|
||||||
return (
|
return (
|
||||||
<AuthLayout
|
<AuthLayout
|
||||||
title="Email vérifié"
|
title={t('auth.verifyEmail.title.success')}
|
||||||
subtitle="Votre email a été vérifié avec succès"
|
subtitle={t('auth.verifyEmail.subtitle.success')}
|
||||||
footerLinks={[{ label: 'Retour à la connexion', to: '/login' }]}
|
footerLinks={footerLinks}
|
||||||
>
|
>
|
||||||
<div className="text-center space-y-4" role="status" aria-live="polite">
|
<div
|
||||||
|
className="text-center space-y-4"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
data-testid="verify-email-success"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="bg-success/10 border border-success text-success px-4 py-4 rounded"
|
className="bg-success/10 border border-success text-success px-4 py-4 rounded"
|
||||||
role="alert"
|
role="alert"
|
||||||
>
|
>
|
||||||
<p className="font-medium">Succès !</p>
|
<p className="font-medium">{t('auth.verifyEmail.success.title')}</p>
|
||||||
<p className="text-sm mt-1">{message}</p>
|
<p className="text-sm mt-1" data-testid="verify-email-message">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
<p className="text-xs mt-2 text-muted-foreground">
|
<p className="text-xs mt-2 text-muted-foreground">
|
||||||
Vous allez être redirigé vers la page de connexion...
|
{t('auth.verifyEmail.success.redirecting')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -184,31 +206,48 @@ export function VerifyEmailPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Afficher le message d'erreur avec options de retry/resend
|
// Error state with retry/resend options
|
||||||
return (
|
return (
|
||||||
<AuthLayout
|
<AuthLayout
|
||||||
title="Vérification de l'email"
|
title={t('auth.verifyEmail.title.error')}
|
||||||
subtitle="Une erreur s'est produite"
|
subtitle={t('auth.verifyEmail.subtitle.error')}
|
||||||
footerLinks={[{ label: 'Retour à la connexion', to: '/login' }]}
|
footerLinks={footerLinks}
|
||||||
>
|
>
|
||||||
<div className="text-center space-y-4">
|
<div className="text-center space-y-4" data-testid="verify-email-error">
|
||||||
<div
|
{resendSuccess ? (
|
||||||
className="bg-destructive/10 border border-destructive text-destructive px-4 py-4 rounded"
|
<div
|
||||||
role="alert"
|
className="bg-success/10 border border-success text-success px-4 py-4 rounded"
|
||||||
aria-live="assertive"
|
role="status"
|
||||||
>
|
aria-live="polite"
|
||||||
<p className="font-medium">Erreur</p>
|
data-testid="verify-email-resend-success"
|
||||||
<p className="text-sm mt-1">{message}</p>
|
>
|
||||||
</div>
|
<p className="font-medium">{t('auth.verifyEmail.success.title')}</p>
|
||||||
|
<p className="text-sm mt-1" data-testid="verify-email-message">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="bg-destructive/10 border border-destructive text-destructive px-4 py-4 rounded"
|
||||||
|
role="alert"
|
||||||
|
aria-live="assertive"
|
||||||
|
>
|
||||||
|
<p className="font-medium">{t('auth.verifyEmail.error.title')}</p>
|
||||||
|
<p className="text-sm mt-1" data-testid="verify-email-message">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{token && (
|
{token && (
|
||||||
<AuthButton
|
<AuthButton
|
||||||
onClick={() => handleVerifyEmail(token)}
|
onClick={handleRetryVerification}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
type="button"
|
type="button"
|
||||||
|
data-testid="verify-email-retry"
|
||||||
>
|
>
|
||||||
Réessayer
|
{t('auth.verifyEmail.button.retry')}
|
||||||
</AuthButton>
|
</AuthButton>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -218,21 +257,30 @@ export function VerifyEmailPage() {
|
||||||
disabled={resendCooldown > 0}
|
disabled={resendCooldown > 0}
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
data-testid="verify-email-resend"
|
||||||
aria-label={
|
aria-label={
|
||||||
resendCooldown > 0
|
resendCooldown > 0
|
||||||
? `Renvoyer l'email de vérification dans ${resendCooldown} secondes`
|
? t('auth.verifyEmail.button.resendCooldownAriaLabel', {
|
||||||
: "Renvoyer l'email de vérification"
|
seconds: String(resendCooldown),
|
||||||
|
})
|
||||||
|
: t('auth.verifyEmail.button.resendAriaLabel')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{resendCooldown > 0 ? (
|
{resendCooldown > 0 ? (
|
||||||
<>
|
<>
|
||||||
<span className="sr-only">
|
<span className="sr-only">
|
||||||
Renvoyer dans {resendCooldown} secondes
|
{t('auth.verifyEmail.button.resendCooldownAriaLabel', {
|
||||||
|
seconds: String(resendCooldown),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span aria-hidden="true">
|
||||||
|
{t('auth.verifyEmail.button.resendCooldown', {
|
||||||
|
seconds: String(resendCooldown),
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
<span aria-hidden="true">Renvoyer dans {resendCooldown}s</span>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Renvoyer l'email de vérification"
|
t('auth.verifyEmail.button.resend')
|
||||||
)}
|
)}
|
||||||
</AuthButton>
|
</AuthButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -97,10 +97,7 @@ export const useAuthStore = create<AuthStore>()(
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
});
|
});
|
||||||
// Persist login error for display after component remount
|
// Error is available via the mutation's error state — no need for sessionStorage
|
||||||
try {
|
|
||||||
sessionStorage.setItem('login_error', apiError.message || 'Identifiants incorrects');
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { fn } from '@storybook/test';
|
import { fn } from 'storybook/test';
|
||||||
import { CreateRoomDialog } from './CreateRoomDialog';
|
import { CreateRoomDialog } from './CreateRoomDialog';
|
||||||
|
|
||||||
const meta: Meta<typeof CreateRoomDialog> = {
|
const meta: Meta<typeof CreateRoomDialog> = {
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,38 @@
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useDashboard } from '../hooks/useDashboard';
|
||||||
|
import type { RecentActivity } from '../services/dashboardService';
|
||||||
|
|
||||||
|
const TYPE_DOT_COLORS: Record<string, string> = {
|
||||||
|
track_upload: 'bg-[var(--sumi-accent)]',
|
||||||
|
message_received: 'bg-[var(--sumi-sage)]',
|
||||||
|
favorite_added: 'bg-[var(--sumi-vermillion)]',
|
||||||
|
playlist_created: 'bg-primary',
|
||||||
|
comment_added: 'bg-warning',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatRelativeTime(timestamp: string): string {
|
||||||
|
const now = Date.now();
|
||||||
|
const then = new Date(timestamp).getTime();
|
||||||
|
const diffMs = now - then;
|
||||||
|
const diffMin = Math.floor(diffMs / 60000);
|
||||||
|
const diffHour = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDay = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
if (diffMin < 1) return 'Just now';
|
||||||
|
if (diffMin < 60) return `${diffMin}m ago`;
|
||||||
|
if (diffHour < 24) return `${diffHour}h ago`;
|
||||||
|
if (diffDay < 7) return `${diffDay}d ago`;
|
||||||
|
return new Date(timestamp).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDotColor(activity: RecentActivity): string {
|
||||||
|
return TYPE_DOT_COLORS[activity.type] || 'bg-muted-foreground';
|
||||||
|
}
|
||||||
|
|
||||||
export function RecentActivityCard() {
|
export function RecentActivityCard() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { recentActivity, isLoading } = useDashboard();
|
||||||
const activities = [
|
|
||||||
{ dotColor: 'bg-[var(--sumi-accent)]', textKey: 'dashboard.activity.newTrackAdded', timeKey: 'dashboard.activity.recently' },
|
|
||||||
{ dotColor: 'bg-[var(--sumi-sage)]', textKey: 'dashboard.activity.messageFrom', timeKey: 'dashboard.activity.recently', params: { user: 'alice' } },
|
|
||||||
{ dotColor: 'bg-[var(--sumi-vermillion)]', textKey: 'dashboard.activity.newFavoriteAdded', timeKey: 'dashboard.activity.recently' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ink-card p-5 md:col-span-2 relative">
|
<div className="ink-card p-5 md:col-span-2 relative">
|
||||||
|
|
@ -17,7 +41,7 @@ export function RecentActivityCard() {
|
||||||
<div className="flex items-center justify-between mb-5">
|
<div className="flex items-center justify-between mb-5">
|
||||||
<h2 className="font-heading text-lg tracking-tight" style={{ fontWeight: 300 }}>{t('dashboard.recentActivity')}</h2>
|
<h2 className="font-heading text-lg tracking-tight" style={{ fontWeight: 300 }}>{t('dashboard.recentActivity')}</h2>
|
||||||
<Link
|
<Link
|
||||||
to="/library"
|
to="/notifications"
|
||||||
className="text-[10px] text-muted-foreground/40 hover:text-foreground transition-colors tracking-[0.15em] uppercase font-heading"
|
className="text-[10px] text-muted-foreground/40 hover:text-foreground transition-colors tracking-[0.15em] uppercase font-heading"
|
||||||
style={{ fontWeight: 300 }}
|
style={{ fontWeight: 300 }}
|
||||||
>
|
>
|
||||||
|
|
@ -29,21 +53,39 @@ export function RecentActivityCard() {
|
||||||
{t('dashboard.recentActivityDescription')}
|
{t('dashboard.recentActivityDescription')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-0">
|
{isLoading ? (
|
||||||
{activities.map((activity, i) => (
|
<div className="space-y-0">
|
||||||
<div key={i} className="flex items-center gap-4 py-3 border-b border-[var(--sumi-border-faint)] last:border-b-0">
|
{[...Array(3)].map((_, i) => (
|
||||||
<div className={`w-1.5 h-1.5 rounded-full ${activity.dotColor} shrink-0`} />
|
<div key={i} className="flex items-center gap-4 py-3 border-b border-[var(--sumi-border-faint)] last:border-b-0 animate-pulse">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="w-1.5 h-1.5 rounded-full bg-muted/30 shrink-0" />
|
||||||
<p className="text-sm text-foreground/80 truncate font-heading" style={{ fontWeight: 300 }}>
|
<div className="flex-1 min-w-0">
|
||||||
{activity.params ? t(activity.textKey, activity.params) : t(activity.textKey)}
|
<div className="h-3.5 bg-muted/20 rounded-sm w-3/4" />
|
||||||
</p>
|
</div>
|
||||||
|
<div className="h-3 bg-muted/20 rounded-sm w-12" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] text-muted-foreground/30 shrink-0 font-heading" style={{ fontWeight: 300 }}>
|
))}
|
||||||
{t(activity.timeKey)}
|
</div>
|
||||||
</span>
|
) : recentActivity.length === 0 ? (
|
||||||
</div>
|
<p className="text-sm text-muted-foreground/40 text-center py-10 font-heading" style={{ fontWeight: 300 }}>
|
||||||
))}
|
{t('dashboard.recentActivityDescription')}
|
||||||
</div>
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-0">
|
||||||
|
{recentActivity.slice(0, 5).map((activity) => (
|
||||||
|
<div key={activity.id} className="flex items-center gap-4 py-3 border-b border-[var(--sumi-border-faint)] last:border-b-0">
|
||||||
|
<div className={`w-1.5 h-1.5 rounded-full ${getDotColor(activity)} shrink-0`} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-foreground/80 truncate font-heading" style={{ fontWeight: 300 }}>
|
||||||
|
{activity.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-muted-foreground/30 shrink-0 font-heading" style={{ fontWeight: 300 }}>
|
||||||
|
{formatRelativeTime(activity.timestamp)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,13 @@ import { useDashboard } from '../hooks/useDashboard';
|
||||||
|
|
||||||
const STAT_META = [
|
const STAT_META = [
|
||||||
{
|
{
|
||||||
titleKey: 'dashboard.stats.tracksInLibrary',
|
titleKey: 'dashboard.stats.tracksListened',
|
||||||
icon: Music,
|
icon: Music,
|
||||||
kanji: '曲',
|
kanji: '曲',
|
||||||
iconColor: 'text-primary',
|
iconColor: 'text-primary',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
titleKey: 'dashboard.stats.playlists',
|
titleKey: 'dashboard.stats.favorites',
|
||||||
icon: Heart,
|
icon: Heart,
|
||||||
kanji: '集',
|
kanji: '集',
|
||||||
iconColor: 'text-destructive',
|
iconColor: 'text-destructive',
|
||||||
|
|
|
||||||
|
|
@ -70,8 +70,8 @@ function WelcomeBanner({ username }: { username: string }) {
|
||||||
|
|
||||||
const QUICK_ACTIONS = [
|
const QUICK_ACTIONS = [
|
||||||
{ icon: Upload, labelKey: 'dashboard.uploadTrack', path: '/library?action=upload', color: 'bg-primary/10 text-primary' },
|
{ icon: Upload, labelKey: 'dashboard.uploadTrack', path: '/library?action=upload', color: 'bg-primary/10 text-primary' },
|
||||||
{ icon: ListMusic, labelKey: 'dashboard.createPlaylist', path: '/library', color: 'bg-success/10 text-success' },
|
{ icon: ListMusic, labelKey: 'dashboard.createPlaylist', path: '/playlists', color: 'bg-success/10 text-success' },
|
||||||
{ icon: Search, labelKey: 'dashboard.discoverMusic', path: '/search', color: 'bg-warning/10 text-warning' },
|
{ icon: Search, labelKey: 'dashboard.discoverMusic', path: '/discover', color: 'bg-warning/10 text-warning' },
|
||||||
{ icon: MessageSquare, labelKey: 'dashboard.openChat', path: '/chat', color: 'bg-info/10 text-info' },
|
{ icon: MessageSquare, labelKey: 'dashboard.openChat', path: '/chat', color: 'bg-info/10 text-info' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { apiClient } from '@/services/api/client';
|
import { apiClient } from '@/services/api/client';
|
||||||
|
import { isCancel } from 'axios';
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/logger';
|
||||||
|
|
||||||
// BE-PAGE-001: Dashboard service for fetching dashboard data
|
// BE-PAGE-001: Dashboard service for fetching dashboard data
|
||||||
|
|
@ -102,6 +103,11 @@ export async function getDashboardData(
|
||||||
library_preview: dashboardData.library_preview,
|
library_preview: dashboardData.library_preview,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Re-throw abort/cancel errors so React Query handles retries properly
|
||||||
|
if (isCancel(error) || (error instanceof DOMException && error.name === 'AbortError')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
logger.error('Failed to fetch dashboard data from aggregated endpoint', {
|
logger.error('Failed to fetch dashboard data from aggregated endpoint', {
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { useCallback, useEffect, useRef } from 'react';
|
||||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
||||||
import { usePlayerStore } from '@/features/player/store/playerStore';
|
import { usePlayerStore } from '@/features/player/store/playerStore';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { ContentFadeIn } from '@/components/ui/ContentFadeIn';
|
import { ContentFadeIn } from '@/components/ui/ContentFadeIn';
|
||||||
import { TrackGrid } from '@/features/tracks/components/TrackGrid';
|
import { TrackGrid } from '@/features/tracks/components/TrackGrid';
|
||||||
import { TrackCardSkeleton } from '@/features/tracks/components/TrackCardSkeleton';
|
import { TrackCardSkeleton } from '@/features/tracks/components/TrackCardSkeleton';
|
||||||
|
|
@ -44,6 +45,7 @@ export function DiscoverPage() {
|
||||||
const play = usePlayerStore((s) => s.play);
|
const play = usePlayerStore((s) => s.play);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const browseGenre = genreFromQuery;
|
const browseGenre = genreFromQuery;
|
||||||
const browseTag = tagFromQuery;
|
const browseTag = tagFromQuery;
|
||||||
|
|
@ -151,7 +153,7 @@ export function DiscoverPage() {
|
||||||
<ContentFadeIn className="min-h-layout-page pb-36">
|
<ContentFadeIn className="min-h-layout-page pb-36">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Music2 className="w-8 h-8 text-primary" />
|
<Music2 className="w-8 h-8 text-primary" />
|
||||||
<h1 className="text-2xl font-heading font-bold">Discover</h1>
|
<h1 className="text-2xl font-heading font-bold">{t('discover.title')}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 mt-6">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 mt-6">
|
||||||
{Array.from({ length: 12 }).map((_, i) => (
|
{Array.from({ length: 12 }).map((_, i) => (
|
||||||
|
|
@ -166,6 +168,8 @@ export function DiscoverPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasEditorialPlaylists = editorialData?.items && editorialData.items.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContentFadeIn className="min-h-layout-page pb-36">
|
<ContentFadeIn className="min-h-layout-page pb-36">
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
|
|
@ -177,9 +181,10 @@ export function DiscoverPage() {
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={goBack}
|
onClick={goBack}
|
||||||
className="-ml-2 hover:bg-[var(--sumi-bg-hover)]"
|
className="-ml-2 hover:bg-[var(--sumi-bg-hover)]"
|
||||||
|
aria-label={t('discover.back')}
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-5 h-5" />
|
<ChevronLeft className="w-5 h-5" />
|
||||||
Back
|
{t('discover.back')}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-primary/30 to-primary/10 flex items-center justify-center">
|
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-primary/30 to-primary/10 flex items-center justify-center">
|
||||||
|
|
@ -191,24 +196,25 @@ export function DiscoverPage() {
|
||||||
? genres?.find((g) => g.slug === browseGenre)?.name ?? browseGenre
|
? genres?.find((g) => g.slug === browseGenre)?.name ?? browseGenre
|
||||||
: browseTag
|
: browseTag
|
||||||
? browseTag
|
? browseTag
|
||||||
: 'Discover'}
|
: t('discover.title')}
|
||||||
</h1>
|
</h1>
|
||||||
{showGenreList && (
|
{showGenreList && (
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">Explore by genre, tag, or editorial playlist</p>
|
<p className="text-sm text-muted-foreground mt-0.5">{t('discover.subtitle')}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showGenreList && genres ? (
|
{showGenreList && genres ? (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4" aria-label={t('discover.byGenre')}>
|
||||||
<h2 className="text-lg font-heading font-semibold tracking-tight">
|
<h2 className="text-lg font-heading font-semibold tracking-tight">
|
||||||
By Genre
|
{t('discover.byGenre')}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
|
||||||
{genres.map((g, i) => (
|
{genres.map((g, i) => (
|
||||||
<button
|
<button
|
||||||
key={g.slug}
|
key={g.slug}
|
||||||
onClick={() => handleGenreClick(g)}
|
onClick={() => handleGenreClick(g)}
|
||||||
|
aria-label={t('discover.browseGenre', { genre: g.name })}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative overflow-hidden rounded-2xl min-h-[7.5rem] p-4 pb-5 text-left',
|
'relative overflow-hidden rounded-2xl min-h-[7.5rem] p-4 pb-5 text-left',
|
||||||
'bg-gradient-to-br',
|
'bg-gradient-to-br',
|
||||||
|
|
@ -224,7 +230,7 @@ export function DiscoverPage() {
|
||||||
</span>
|
</span>
|
||||||
{'count' in g && (g as Genre & { count?: number }).count != null && (
|
{'count' in g && (g as Genre & { count?: number }).count != null && (
|
||||||
<span className="relative z-10 block mt-1 text-xs text-white/60 font-medium">
|
<span className="relative z-10 block mt-1 text-xs text-white/60 font-medium">
|
||||||
{(g as Genre & { count?: number }).count} tracks
|
{t('discover.trackCount', { count: (g as Genre & { count?: number }).count })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{/* Decorative circles */}
|
{/* Decorative circles */}
|
||||||
|
|
@ -239,10 +245,10 @@ export function DiscoverPage() {
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{showGenreList ? (
|
{showGenreList ? (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4" aria-label={t('discover.editorialPlaylists')}>
|
||||||
<div className="section-divider my-2" />
|
<div className="section-divider my-2" />
|
||||||
<h2 className="text-lg font-heading font-semibold tracking-tight">
|
<h2 className="text-lg font-heading font-semibold tracking-tight">
|
||||||
Editorial Playlists
|
{t('discover.editorialPlaylists')}
|
||||||
</h2>
|
</h2>
|
||||||
{editorialLoading ? (
|
{editorialLoading ? (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
|
|
@ -250,7 +256,7 @@ export function DiscoverPage() {
|
||||||
<PlaylistCardSkeleton key={i} />
|
<PlaylistCardSkeleton key={i} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : editorialData?.items && editorialData.items.length > 0 ? (
|
) : hasEditorialPlaylists ? (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
{editorialData.items.map((pl) => (
|
{editorialData.items.map((pl) => (
|
||||||
<PlaylistCard
|
<PlaylistCard
|
||||||
|
|
@ -269,7 +275,11 @@ export function DiscoverPage() {
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground py-4">
|
||||||
|
{t('discover.noEditorialPlaylists')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
@ -287,7 +297,7 @@ export function DiscoverPage() {
|
||||||
<>
|
<>
|
||||||
<TrackGrid
|
<TrackGrid
|
||||||
tracks={tracks}
|
tracks={tracks}
|
||||||
emptyMessage="No tracks in this genre"
|
emptyMessage={t('discover.noTracksInGenre')}
|
||||||
onTrackPlay={handlePlay}
|
onTrackPlay={handlePlay}
|
||||||
onTrackClick={handleTrackClick}
|
onTrackClick={handleTrackClick}
|
||||||
gap="md"
|
gap="md"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { within, userEvent, expect } from '@storybook/test';
|
import { within, userEvent, expect } from 'storybook/test';
|
||||||
import { withRouter } from '../../../stories/decorators';
|
import { withRouter } from '../../../stories/decorators';
|
||||||
import ServerErrorPage from './ServerErrorPage';
|
import ServerErrorPage from './ServerErrorPage';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,11 @@ import { Link } from 'react-router-dom';
|
||||||
import { Avatar } from '@/components/ui/avatar';
|
import { Avatar } from '@/components/ui/avatar';
|
||||||
import { getSuggestions } from '@/features/profile/services/profileService';
|
import { getSuggestions } from '@/features/profile/services/profileService';
|
||||||
import { FollowButton } from '@/features/profile/components/FollowButton';
|
import { FollowButton } from '@/features/profile/components/FollowButton';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { UserPlus, Loader2, ChevronRight } from 'lucide-react';
|
import { UserPlus, Loader2, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
export function SuggestionsWidget() {
|
export function SuggestionsWidget() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['suggestions'],
|
queryKey: ['suggestions'],
|
||||||
queryFn: () => getSuggestions(5),
|
queryFn: () => getSuggestions(5),
|
||||||
|
|
@ -23,7 +25,7 @@ export function SuggestionsWidget() {
|
||||||
<div className="p-4 pb-3">
|
<div className="p-4 pb-3">
|
||||||
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
|
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
|
||||||
<UserPlus className="w-4 h-4 text-primary" />
|
<UserPlus className="w-4 h-4 text-primary" />
|
||||||
Suggested Accounts
|
{t('feed.suggestedAccounts')}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-center py-10 px-4">
|
<div className="flex items-center justify-center py-10 px-4">
|
||||||
|
|
@ -64,7 +66,7 @@ export function SuggestionsWidget() {
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-medium text-foreground truncate">@{user.username}</p>
|
<p className="text-sm font-medium text-foreground truncate">@{user.username}</p>
|
||||||
<p className="text-xs text-muted-foreground/80">
|
<p className="text-xs text-muted-foreground/80">
|
||||||
{(user.followers_count ?? 0).toLocaleString()} followers
|
{t('feed.followers', { count: user.followers_count ?? 0 })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -79,7 +81,7 @@ export function SuggestionsWidget() {
|
||||||
to="/social"
|
to="/social"
|
||||||
className="text-xs font-medium text-muted-foreground hover:text-primary transition-colors duration-[var(--sumi-duration-fast)] flex items-center gap-1 group/link"
|
className="text-xs font-medium text-muted-foreground hover:text-primary transition-colors duration-[var(--sumi-duration-fast)] flex items-center gap-1 group/link"
|
||||||
>
|
>
|
||||||
See all
|
{t('feed.seeAll')}
|
||||||
<ChevronRight className="w-3 h-3 group-hover/link:translate-x-0.5 transition-transform duration-[var(--sumi-duration-fast)]" />
|
<ChevronRight className="w-3 h-3 group-hover/link:translate-x-0.5 transition-transform duration-[var(--sumi-duration-fast)]" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { ErrorDisplay } from '@/components/ui/ErrorDisplay';
|
||||||
import { SuggestionsWidget } from '../components/SuggestionsWidget';
|
import { SuggestionsWidget } from '../components/SuggestionsWidget';
|
||||||
import { feedService } from '@/services/feedService';
|
import { feedService } from '@/services/feedService';
|
||||||
import { Music2, Loader2, Disc3, Sparkles } from 'lucide-react';
|
import { Music2, Loader2, Disc3, Sparkles } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
|
||||||
export function FeedPage() {
|
export function FeedPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -155,7 +155,7 @@ export function FeedPage() {
|
||||||
</h2>
|
</h2>
|
||||||
<TrackGrid
|
<TrackGrid
|
||||||
tracks={tracks}
|
tracks={tracks}
|
||||||
emptyMessage="No new tracks"
|
emptyMessage={t('feed.noNewTracks')}
|
||||||
onTrackPlay={handlePlay}
|
onTrackPlay={handlePlay}
|
||||||
onTrackClick={handleTrackClick}
|
onTrackClick={handleTrackClick}
|
||||||
gap="md"
|
gap="md"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useRef } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { motion, useInView } from 'framer-motion';
|
import { motion, useInView } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
Globe,
|
Globe,
|
||||||
Heart,
|
Heart,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════════
|
||||||
TALAS LANDING PAGE — Pre-launch
|
TALAS LANDING PAGE — Pre-launch
|
||||||
|
|
@ -57,13 +58,20 @@ function Section({ children, className = '', id }: { children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LandingPage() {
|
export default function LandingPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||||
const [errorMsg, setErrorMsg] = useState('');
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
|
|
||||||
|
// BUG-09 fix: Set page-specific document.title
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = t('landing.pageTitle');
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
const handleSubscribe = async (e: React.FormEvent) => {
|
const handleSubscribe = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!email || !email.includes('@')) return;
|
// BUG-13 fix: Better email validation
|
||||||
|
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return;
|
||||||
setStatus('loading');
|
setStatus('loading');
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/v1/newsletter/subscribe', {
|
const res = await fetch('/api/v1/newsletter/subscribe', {
|
||||||
|
|
@ -73,19 +81,71 @@ export default function LandingPage() {
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => null);
|
const data = await res.json().catch(() => null);
|
||||||
throw new Error(data?.error?.message || 'Subscription failed');
|
// BUG-02 fix: Use i18n for error messages instead of hardcoded English
|
||||||
|
throw new Error(data?.error?.message || t('landing.form.errorSubscription'));
|
||||||
}
|
}
|
||||||
setStatus('success');
|
setStatus('success');
|
||||||
setEmail('');
|
setEmail('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setErrorMsg(err instanceof Error ? err.message : 'An error occurred');
|
setErrorMsg(err instanceof Error ? err.message : t('landing.form.errorGeneric'));
|
||||||
setTimeout(() => setStatus('idle'), 4000);
|
setTimeout(() => setStatus('idle'), 4000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const valueCards = [
|
||||||
|
{
|
||||||
|
icon: Wrench,
|
||||||
|
kanji: '開',
|
||||||
|
title: t('landing.values.card1.title'),
|
||||||
|
desc: t('landing.values.card1.desc'),
|
||||||
|
accent: 'var(--sumi-accent)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Shield,
|
||||||
|
kanji: '守',
|
||||||
|
title: t('landing.values.card2.title'),
|
||||||
|
desc: t('landing.values.card2.desc'),
|
||||||
|
accent: 'var(--sumi-sage)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Users,
|
||||||
|
kanji: '結',
|
||||||
|
title: t('landing.values.card3.title'),
|
||||||
|
desc: t('landing.values.card3.desc'),
|
||||||
|
accent: 'var(--sumi-kin)',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const productFeatures = [
|
||||||
|
{ icon: Eye, text: t('landing.product.feat1') },
|
||||||
|
{ icon: Wrench, text: t('landing.product.feat2') },
|
||||||
|
{ icon: Globe, text: t('landing.product.feat3') },
|
||||||
|
{ icon: Lock, text: t('landing.product.feat4') },
|
||||||
|
];
|
||||||
|
|
||||||
|
const platformFeatures = [
|
||||||
|
{ icon: Music, label: t('landing.platform.streaming') },
|
||||||
|
{ icon: Users, label: t('landing.platform.community') },
|
||||||
|
{ icon: Heart, label: t('landing.platform.marketplace') },
|
||||||
|
{ icon: Lock, label: t('landing.platform.privacy') },
|
||||||
|
{ icon: Globe, label: t('landing.platform.openSource') },
|
||||||
|
{ icon: Shield, label: t('landing.platform.zeroTracking') },
|
||||||
|
];
|
||||||
|
|
||||||
|
const footerLinks = [
|
||||||
|
{ label: t('landing.footer.openSource'), href: 'https://github.com/talas-audio', external: true },
|
||||||
|
{ label: t('landing.footer.privacy'), href: '/privacy', external: false },
|
||||||
|
{ label: t('landing.footer.contact'), href: 'mailto:contact@talas.audio', external: false },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[var(--sumi-bg-void)] text-[var(--sumi-text-primary)] overflow-x-hidden">
|
// BUG-03 fix: Use <main> as the root landmark element
|
||||||
|
<main
|
||||||
|
id="main-content"
|
||||||
|
className="min-h-screen bg-[var(--sumi-bg-void)] text-[var(--sumi-text-primary)] overflow-x-hidden"
|
||||||
|
data-testid="launch-page"
|
||||||
|
>
|
||||||
{/* ═══ ATMOSPHERE — Ink wash background ═══ */}
|
{/* ═══ ATMOSPHERE — Ink wash background ═══ */}
|
||||||
<div className="fixed inset-0 pointer-events-none" aria-hidden="true">
|
<div className="fixed inset-0 pointer-events-none" aria-hidden="true">
|
||||||
<div
|
<div
|
||||||
|
|
@ -112,9 +172,14 @@ export default function LandingPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ═══ NAVIGATION ═══ */}
|
{/* ═══ NAVIGATION ═══ */}
|
||||||
<nav className="fixed top-0 inset-x-0 z-50 border-b border-[var(--sumi-border-faint)] bg-[var(--sumi-bg-void)]/80 backdrop-blur-xl">
|
{/* BUG-10 fix: Added aria-label. BUG-15 fix: Added flex-wrap and overflow handling for mobile */}
|
||||||
<div className="max-w-[1200px] mx-auto px-6 h-14 flex items-center justify-between">
|
<nav
|
||||||
<div className="flex items-center gap-3">
|
className="fixed top-0 inset-x-0 z-50 border-b border-[var(--sumi-border-faint)] bg-[var(--sumi-bg-void)]/80 backdrop-blur-xl"
|
||||||
|
aria-label={t('landing.nav.ariaLabel')}
|
||||||
|
data-testid="launch-nav"
|
||||||
|
>
|
||||||
|
<div className="max-w-[1200px] mx-auto px-4 sm:px-6 h-14 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
<div className="h-8 w-8 rounded-sm bg-[var(--sumi-accent)] flex items-center justify-center hanko-seal" style={{ transform: 'rotate(-2deg)' }}>
|
<div className="h-8 w-8 rounded-sm bg-[var(--sumi-accent)] flex items-center justify-center hanko-seal" style={{ transform: 'rotate(-2deg)' }}>
|
||||||
<span className="text-[var(--sumi-bg-base)] font-[var(--sumi-font-heading)] text-sm font-semibold relative z-10">T</span>
|
<span className="text-[var(--sumi-bg-base)] font-[var(--sumi-font-heading)] text-sm font-semibold relative z-10">T</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -125,34 +190,35 @@ export default function LandingPage() {
|
||||||
TALAS
|
TALAS
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-3 sm:gap-6 overflow-hidden">
|
||||||
<a
|
<a
|
||||||
href="#product"
|
href="#product"
|
||||||
className="text-xs tracking-[0.15em] text-[var(--sumi-text-tertiary)] hover:text-[var(--sumi-text-primary)] transition-colors"
|
className="hidden sm:inline text-xs tracking-[0.15em] text-[var(--sumi-text-tertiary)] hover:text-[var(--sumi-text-primary)] transition-colors"
|
||||||
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
|
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
|
||||||
>
|
>
|
||||||
PRODUIT
|
{t('landing.nav.product')}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="#platform"
|
href="#platform"
|
||||||
className="text-xs tracking-[0.15em] text-[var(--sumi-text-tertiary)] hover:text-[var(--sumi-text-primary)] transition-colors"
|
className="hidden sm:inline text-xs tracking-[0.15em] text-[var(--sumi-text-tertiary)] hover:text-[var(--sumi-text-primary)] transition-colors"
|
||||||
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
|
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
|
||||||
>
|
>
|
||||||
PLATEFORME
|
{t('landing.nav.platform')}
|
||||||
</a>
|
</a>
|
||||||
<Link
|
<Link
|
||||||
to="/login"
|
to="/login"
|
||||||
className="text-xs tracking-[0.1em] px-4 py-1.5 border border-[var(--sumi-border-strong)] rounded-sm text-[var(--sumi-text-secondary)] hover:text-[var(--sumi-text-primary)] hover:border-[var(--sumi-accent)]/40 transition-all"
|
className="text-xs tracking-[0.1em] px-4 py-1.5 border border-[var(--sumi-border-strong)] rounded-sm text-[var(--sumi-text-secondary)] hover:text-[var(--sumi-text-primary)] hover:border-[var(--sumi-accent)]/40 transition-all whitespace-nowrap"
|
||||||
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
|
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
|
||||||
|
data-testid="launch-login-link"
|
||||||
>
|
>
|
||||||
CONNEXION
|
{t('landing.nav.login')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* ═══ HERO ═══ */}
|
{/* ═══ HERO ═══ */}
|
||||||
<header className="relative z-10 min-h-screen flex flex-col items-center justify-center px-6 pt-14">
|
<header className="relative z-10 min-h-screen flex flex-col items-center justify-center px-6 pt-14" data-testid="launch-hero">
|
||||||
<motion.div
|
<motion.div
|
||||||
className="max-w-[800px] mx-auto text-center"
|
className="max-w-[800px] mx-auto text-center"
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
|
|
@ -188,9 +254,9 @@ export default function LandingPage() {
|
||||||
className="text-base sm:text-lg text-[var(--sumi-text-secondary)] leading-relaxed max-w-[550px] mx-auto mb-4"
|
className="text-base sm:text-lg text-[var(--sumi-text-secondary)] leading-relaxed max-w-[550px] mx-auto mb-4"
|
||||||
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
|
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
|
||||||
>
|
>
|
||||||
Matériel audio professionnel — ouvert, réparable, transparent.
|
{t('landing.hero.tagline1')}
|
||||||
<br />
|
<br />
|
||||||
Plateforme musicale éthique — sans tracking, sans algorithme.
|
{t('landing.hero.tagline2')}
|
||||||
</motion.p>
|
</motion.p>
|
||||||
|
|
||||||
<motion.p
|
<motion.p
|
||||||
|
|
@ -199,7 +265,7 @@ export default function LandingPage() {
|
||||||
className="text-xs tracking-[0.25em] text-[var(--sumi-text-tertiary)] uppercase mb-12"
|
className="text-xs tracking-[0.25em] text-[var(--sumi-text-tertiary)] uppercase mb-12"
|
||||||
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
|
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
|
||||||
>
|
>
|
||||||
Lancement bientôt — Rejoins les premiers
|
{t('landing.hero.cta')}
|
||||||
</motion.p>
|
</motion.p>
|
||||||
|
|
||||||
{/* Email capture — hero */}
|
{/* Email capture — hero */}
|
||||||
|
|
@ -208,12 +274,13 @@ export default function LandingPage() {
|
||||||
custom={4}
|
custom={4}
|
||||||
onSubmit={handleSubscribe}
|
onSubmit={handleSubscribe}
|
||||||
className="flex flex-col sm:flex-row items-center gap-3 max-w-[460px] mx-auto"
|
className="flex flex-col sm:flex-row items-center gap-3 max-w-[460px] mx-auto"
|
||||||
|
data-testid="launch-hero-form"
|
||||||
>
|
>
|
||||||
{status === 'success' ? (
|
{status === 'success' ? (
|
||||||
<div className="flex items-center gap-2 text-[var(--sumi-sage)] text-sm py-3">
|
<div className="flex items-center gap-2 text-[var(--sumi-sage)] text-sm py-3" data-testid="launch-hero-success">
|
||||||
<Check size={16} />
|
<Check size={16} />
|
||||||
<span style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}>
|
<span style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}>
|
||||||
Inscription confirmée. À bientôt.
|
{t('landing.form.successHero')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -222,23 +289,29 @@ export default function LandingPage() {
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="ton@email.com"
|
placeholder={t('landing.hero.placeholder')}
|
||||||
required
|
required
|
||||||
aria-label="Adresse email"
|
aria-label={t('landing.notify.ariaLabel')}
|
||||||
|
data-testid="launch-hero-email"
|
||||||
className="flex-1 w-full sm:w-auto px-4 py-2.5 bg-[var(--sumi-surface-card)] border border-[var(--sumi-border-default)] rounded-sm text-sm text-[var(--sumi-text-primary)] placeholder:text-[var(--sumi-text-disabled)] focus:outline-none focus:border-[var(--sumi-accent)]/50 focus:shadow-[var(--sumi-shadow-glow)] transition-all"
|
className="flex-1 w-full sm:w-auto px-4 py-2.5 bg-[var(--sumi-surface-card)] border border-[var(--sumi-border-default)] rounded-sm text-sm text-[var(--sumi-text-primary)] placeholder:text-[var(--sumi-text-disabled)] focus:outline-none focus:border-[var(--sumi-accent)]/50 focus:shadow-[var(--sumi-shadow-glow)] transition-all"
|
||||||
style={{ fontFamily: 'var(--sumi-font-body)' }}
|
style={{ fontFamily: 'var(--sumi-font-body)' }}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={status === 'loading'}
|
disabled={status === 'loading'}
|
||||||
|
data-testid="launch-hero-submit"
|
||||||
className="w-full sm:w-auto px-6 py-2.5 bg-[var(--sumi-accent)] text-[var(--sumi-text-inverse)] rounded-sm text-sm tracking-[0.1em] hover:bg-[var(--sumi-accent-hover)] disabled:opacity-50 transition-colors flex items-center justify-center gap-2"
|
className="w-full sm:w-auto px-6 py-2.5 bg-[var(--sumi-accent)] text-[var(--sumi-text-inverse)] rounded-sm text-sm tracking-[0.1em] hover:bg-[var(--sumi-accent-hover)] disabled:opacity-50 transition-colors flex items-center justify-center gap-2"
|
||||||
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 400 }}
|
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 400 }}
|
||||||
>
|
>
|
||||||
{status === 'loading' ? (
|
{status === 'loading' ? (
|
||||||
<span className="h-4 w-4 border border-current border-t-transparent rounded-full animate-spin" />
|
// BUG-11 fix: Added role="status" and sr-only text for spinner
|
||||||
|
<span role="status">
|
||||||
|
<span className="h-4 w-4 border border-current border-t-transparent rounded-full animate-spin inline-block" aria-hidden="true" />
|
||||||
|
<span className="sr-only">{t('landing.form.loadingAriaLabel')}</span>
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
REJOINDRE
|
{t('landing.hero.submit')}
|
||||||
<ArrowRight size={14} />
|
<ArrowRight size={14} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -248,7 +321,7 @@ export default function LandingPage() {
|
||||||
</motion.form>
|
</motion.form>
|
||||||
|
|
||||||
{status === 'error' && (
|
{status === 'error' && (
|
||||||
<p className="text-xs text-[var(--sumi-vermillion)] mt-2">{errorMsg}</p>
|
<p className="text-xs text-[var(--sumi-vermillion)] mt-2" data-testid="launch-error-message">{errorMsg}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Scroll hint */}
|
{/* Scroll hint */}
|
||||||
|
|
@ -259,62 +332,40 @@ export default function LandingPage() {
|
||||||
>
|
>
|
||||||
<div className="w-px h-12 bg-gradient-to-b from-[var(--sumi-border-strong)] to-transparent mx-auto mb-2" />
|
<div className="w-px h-12 bg-gradient-to-b from-[var(--sumi-border-strong)] to-transparent mx-auto mb-2" />
|
||||||
<span className="text-[10px] tracking-[0.3em] text-[var(--sumi-text-disabled)]" style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}>
|
<span className="text-[10px] tracking-[0.3em] text-[var(--sumi-text-disabled)]" style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}>
|
||||||
DÉCOUVRIR
|
{t('landing.hero.discover')}
|
||||||
</span>
|
</span>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* ═══ VALUES — Three pillars ═══ */}
|
{/* ═══ VALUES — Three pillars ═══ */}
|
||||||
<Section className="relative z-10 py-32 px-6">
|
<Section className="relative z-10 py-32 px-6" data-testid="launch-values">
|
||||||
<div className="max-w-[1000px] mx-auto">
|
<div className="max-w-[1000px] mx-auto">
|
||||||
<motion.div variants={inkReveal} custom={0} className="text-center mb-20">
|
<motion.div variants={inkReveal} custom={0} className="text-center mb-20">
|
||||||
<span className="text-[10px] tracking-[0.3em] text-[var(--sumi-kin)] uppercase" style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}>
|
<span className="text-[10px] tracking-[0.3em] text-[var(--sumi-kin)] uppercase" style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}>
|
||||||
三つの柱
|
{t('landing.values.kicker')}
|
||||||
</span>
|
</span>
|
||||||
<h2
|
<h2
|
||||||
className="text-3xl sm:text-4xl mt-3 tracking-[0.05em]"
|
className="text-3xl sm:text-4xl mt-3 tracking-[0.05em]"
|
||||||
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 200 }}
|
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 200 }}
|
||||||
>
|
>
|
||||||
Trois engagements
|
{t('landing.values.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="w-16 h-px bg-gradient-to-r from-transparent via-[var(--sumi-kin)]/30 to-transparent mx-auto mt-6" />
|
<div className="w-16 h-px bg-gradient-to-r from-transparent via-[var(--sumi-kin)]/30 to-transparent mx-auto mt-6" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
{[
|
{valueCards.map((card, i) => (
|
||||||
{
|
|
||||||
icon: Wrench,
|
|
||||||
kanji: '開',
|
|
||||||
title: 'Hardware Ouvert',
|
|
||||||
desc: 'Schémas publiés sous licence CERN-OHL. Tu peux construire, réparer et améliorer chaque composant. Pas d\'obsolescence programmée.',
|
|
||||||
accent: 'var(--sumi-accent)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Shield,
|
|
||||||
kanji: '守',
|
|
||||||
title: 'Plateforme Éthique',
|
|
||||||
desc: 'Zéro tracking comportemental. Zéro algorithme de manipulation. Flux chronologique. Données privées. Code open-source (AGPL-3.0).',
|
|
||||||
accent: 'var(--sumi-sage)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Users,
|
|
||||||
kanji: '結',
|
|
||||||
title: 'Communauté Artiste',
|
|
||||||
desc: 'Streaming, marketplace, chat en temps réel, playlists collaboratives. Rémunération transparente. Les artistes contrôlent leur musique.',
|
|
||||||
accent: 'var(--sumi-kin)',
|
|
||||||
},
|
|
||||||
].map((card, i) => (
|
|
||||||
<motion.div
|
<motion.div
|
||||||
key={card.title}
|
key={card.title}
|
||||||
variants={inkReveal}
|
variants={inkReveal}
|
||||||
custom={i + 1}
|
custom={i + 1}
|
||||||
className="ink-card p-8 bg-[var(--sumi-surface-card)]/60 backdrop-blur-sm border border-[var(--sumi-border-faint)] group hover:border-[var(--sumi-border-strong)] transition-all duration-500"
|
className="ink-card p-8 bg-[var(--sumi-surface-card)]/60 backdrop-blur-sm border border-[var(--sumi-border-faint)] group hover:border-[var(--sumi-border-strong)] transition-all duration-500"
|
||||||
>
|
>
|
||||||
{/* Ghost kanji in card */}
|
|
||||||
<span
|
<span
|
||||||
className="absolute -right-2 -top-4 text-[100px] opacity-[0.025] pointer-events-none select-none"
|
className="absolute -right-2 -top-4 text-[100px] opacity-[0.025] pointer-events-none select-none"
|
||||||
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 200 }}
|
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 200 }}
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{card.kanji}
|
{card.kanji}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -346,7 +397,7 @@ export default function LandingPage() {
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* ═══ PRODUCT TEASER — Condenser microphone ═══ */}
|
{/* ═══ PRODUCT TEASER — Condenser microphone ═══ */}
|
||||||
<Section className="relative z-10 py-32 px-6" id="product">
|
<Section className="relative z-10 py-32 px-6" id="product" data-testid="launch-product">
|
||||||
<div className="max-w-[1000px] mx-auto">
|
<div className="max-w-[1000px] mx-auto">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-16 items-center">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-16 items-center">
|
||||||
{/* Microphone illustration — abstract */}
|
{/* Microphone illustration — abstract */}
|
||||||
|
|
@ -356,7 +407,6 @@ export default function LandingPage() {
|
||||||
className="relative flex items-center justify-center"
|
className="relative flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<div className="relative w-[280px] h-[380px]">
|
<div className="relative w-[280px] h-[380px]">
|
||||||
{/* Ink wash backdrop */}
|
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 rounded-sm"
|
className="absolute inset-0 rounded-sm"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -367,16 +417,13 @@ export default function LandingPage() {
|
||||||
border: '1px solid var(--sumi-border-faint)',
|
border: '1px solid var(--sumi-border-faint)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Microphone silhouette */}
|
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||||
<Mic size={80} strokeWidth={0.8} className="text-[var(--sumi-text-tertiary)] mb-6" />
|
<Mic size={80} strokeWidth={0.8} className="text-[var(--sumi-text-tertiary)] mb-6" />
|
||||||
<div className="w-px h-20 bg-gradient-to-b from-[var(--sumi-text-tertiary)]/30 to-transparent" />
|
<div className="w-px h-20 bg-gradient-to-b from-[var(--sumi-text-tertiary)]/30 to-transparent" />
|
||||||
<div className="w-12 h-1 rounded-full bg-[var(--sumi-text-tertiary)]/15 mt-1" />
|
<div className="w-12 h-1 rounded-full bg-[var(--sumi-text-tertiary)]/15 mt-1" />
|
||||||
</div>
|
</div>
|
||||||
{/* Gold accent corner */}
|
|
||||||
<div className="absolute top-4 right-4 w-8 h-px bg-[var(--sumi-kin)]/30" />
|
<div className="absolute top-4 right-4 w-8 h-px bg-[var(--sumi-kin)]/30" />
|
||||||
<div className="absolute top-4 right-4 w-px h-8 bg-[var(--sumi-kin)]/30" />
|
<div className="absolute top-4 right-4 w-px h-8 bg-[var(--sumi-kin)]/30" />
|
||||||
{/* Seal */}
|
|
||||||
<div className="absolute bottom-6 right-6 h-10 w-10 rounded-sm bg-[var(--sumi-accent)]/10 border border-[var(--sumi-accent)]/20 flex items-center justify-center">
|
<div className="absolute bottom-6 right-6 h-10 w-10 rounded-sm bg-[var(--sumi-accent)]/10 border border-[var(--sumi-accent)]/20 flex items-center justify-center">
|
||||||
<span className="text-[var(--sumi-accent)] text-xs" style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 500 }}>T</span>
|
<span className="text-[var(--sumi-accent)] text-xs" style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 500 }}>T</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -391,7 +438,7 @@ export default function LandingPage() {
|
||||||
className="text-[10px] tracking-[0.3em] text-[var(--sumi-kin)] uppercase"
|
className="text-[10px] tracking-[0.3em] text-[var(--sumi-kin)] uppercase"
|
||||||
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
|
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
|
||||||
>
|
>
|
||||||
Premier produit
|
{t('landing.product.kicker')}
|
||||||
</motion.span>
|
</motion.span>
|
||||||
|
|
||||||
<motion.h2
|
<motion.h2
|
||||||
|
|
@ -400,29 +447,23 @@ export default function LandingPage() {
|
||||||
className="text-3xl sm:text-4xl mt-3 mb-2 tracking-[0.03em]"
|
className="text-3xl sm:text-4xl mt-3 mb-2 tracking-[0.03em]"
|
||||||
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 200 }}
|
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 200 }}
|
||||||
>
|
>
|
||||||
Microphone Condensateur
|
{t('landing.product.title')}
|
||||||
</motion.h2>
|
</motion.h2>
|
||||||
|
|
||||||
<motion.div variants={brushStroke} className="w-20 h-px bg-gradient-to-r from-[var(--sumi-accent)]/40 to-transparent mb-8" />
|
<motion.div variants={brushStroke} className="w-20 h-px bg-gradient-to-r from-[var(--sumi-accent)]/40 to-transparent mb-8" />
|
||||||
|
|
||||||
|
{/* BUG-06 fix: Removed duplicate text, use single i18n key */}
|
||||||
<motion.p
|
<motion.p
|
||||||
variants={inkReveal}
|
variants={inkReveal}
|
||||||
custom={2}
|
custom={2}
|
||||||
className="text-sm text-[var(--sumi-text-secondary)] leading-relaxed mb-8"
|
className="text-sm text-[var(--sumi-text-secondary)] leading-relaxed mb-8"
|
||||||
style={{ fontFamily: 'var(--sumi-font-body)' }}
|
style={{ fontFamily: 'var(--sumi-font-body)' }}
|
||||||
>
|
>
|
||||||
Large diaphragme. Préampli OPA1642. Corps aluminium usiné.
|
{t('landing.product.desc')}
|
||||||
Schémas publiés, composants standards, guide de réparation inclus dans la boîte.
|
|
||||||
Garantie 5 ans. Composants standards. Guide de réparation dans la boîte.
|
|
||||||
</motion.p>
|
</motion.p>
|
||||||
|
|
||||||
<motion.ul variants={inkReveal} custom={3} className="space-y-3 mb-10">
|
<motion.ul variants={inkReveal} custom={3} className="space-y-3 mb-10">
|
||||||
{[
|
{productFeatures.map((item) => (
|
||||||
{ icon: Eye, text: 'Schémas KiCAD publiés — CERN-OHL-W' },
|
|
||||||
{ icon: Wrench, text: 'Réparable — pas de colle, composants standards' },
|
|
||||||
{ icon: Globe, text: 'Fabriqué en France — sourcing documenté' },
|
|
||||||
{ icon: Lock, text: '~150 € — transparence totale des coûts' },
|
|
||||||
].map((item) => (
|
|
||||||
<li key={item.text} className="flex items-start gap-3 text-sm text-[var(--sumi-text-secondary)]">
|
<li key={item.text} className="flex items-start gap-3 text-sm text-[var(--sumi-text-secondary)]">
|
||||||
<item.icon size={15} className="text-[var(--sumi-accent)] mt-0.5 shrink-0" strokeWidth={1.5} />
|
<item.icon size={15} className="text-[var(--sumi-accent)] mt-0.5 shrink-0" strokeWidth={1.5} />
|
||||||
<span style={{ fontFamily: 'var(--sumi-font-body)' }}>{item.text}</span>
|
<span style={{ fontFamily: 'var(--sumi-font-body)' }}>{item.text}</span>
|
||||||
|
|
@ -436,7 +477,7 @@ export default function LandingPage() {
|
||||||
className="inline-flex items-center gap-2 text-xs tracking-[0.15em] text-[var(--sumi-accent)] hover:text-[var(--sumi-accent-hover)] transition-colors"
|
className="inline-flex items-center gap-2 text-xs tracking-[0.15em] text-[var(--sumi-accent)] hover:text-[var(--sumi-accent-hover)] transition-colors"
|
||||||
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 400 }}
|
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 400 }}
|
||||||
>
|
>
|
||||||
ÊTRE NOTIFIÉ DU LANCEMENT
|
{t('landing.product.cta')}
|
||||||
<ArrowRight size={13} />
|
<ArrowRight size={13} />
|
||||||
</a>
|
</a>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
@ -446,7 +487,7 @@ export default function LandingPage() {
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* ═══ PLATFORM TEASER — Veza ═══ */}
|
{/* ═══ PLATFORM TEASER — Veza ═══ */}
|
||||||
<Section className="relative z-10 py-32 px-6" id="platform">
|
<Section className="relative z-10 py-32 px-6" id="platform" data-testid="launch-platform">
|
||||||
<div className="max-w-[1000px] mx-auto text-center">
|
<div className="max-w-[1000px] mx-auto text-center">
|
||||||
<motion.span
|
<motion.span
|
||||||
variants={inkReveal}
|
variants={inkReveal}
|
||||||
|
|
@ -454,7 +495,7 @@ export default function LandingPage() {
|
||||||
className="text-[10px] tracking-[0.3em] text-[var(--sumi-kin)] uppercase"
|
className="text-[10px] tracking-[0.3em] text-[var(--sumi-kin)] uppercase"
|
||||||
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
|
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
|
||||||
>
|
>
|
||||||
La plateforme
|
{t('landing.platform.kicker')}
|
||||||
</motion.span>
|
</motion.span>
|
||||||
|
|
||||||
<motion.h2
|
<motion.h2
|
||||||
|
|
@ -474,18 +515,11 @@ export default function LandingPage() {
|
||||||
className="text-xs tracking-[0.2em] text-[var(--sumi-text-tertiary)] mb-12"
|
className="text-xs tracking-[0.2em] text-[var(--sumi-text-tertiary)] mb-12"
|
||||||
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
|
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
|
||||||
>
|
>
|
||||||
墨 STREAMING — DU MICRO À L'AUDITEUR
|
{t('landing.platform.subtitle')}
|
||||||
</motion.p>
|
</motion.p>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-6 max-w-[700px] mx-auto mb-16">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-6 max-w-[700px] mx-auto mb-16">
|
||||||
{[
|
{platformFeatures.map((feat, i) => (
|
||||||
{ icon: Music, label: 'Streaming HLS' },
|
|
||||||
{ icon: Users, label: 'Communauté' },
|
|
||||||
{ icon: Heart, label: 'Marketplace' },
|
|
||||||
{ icon: Lock, label: 'Vie privée' },
|
|
||||||
{ icon: Globe, label: 'Open source' },
|
|
||||||
{ icon: Shield, label: 'Zéro tracking' },
|
|
||||||
].map((feat, i) => (
|
|
||||||
<motion.div
|
<motion.div
|
||||||
key={feat.label}
|
key={feat.label}
|
||||||
variants={inkReveal}
|
variants={inkReveal}
|
||||||
|
|
@ -509,15 +543,13 @@ export default function LandingPage() {
|
||||||
className="text-sm text-[var(--sumi-text-tertiary)] max-w-[500px] mx-auto leading-relaxed"
|
className="text-sm text-[var(--sumi-text-tertiary)] max-w-[500px] mx-auto leading-relaxed"
|
||||||
style={{ fontFamily: 'var(--sumi-font-body)' }}
|
style={{ fontFamily: 'var(--sumi-font-body)' }}
|
||||||
>
|
>
|
||||||
435 000 lignes de code. Audit de sécurité externe. 34 suites de tests.
|
{t('landing.platform.stats')}
|
||||||
Backend Go + Stream server Rust + Frontend React.
|
|
||||||
Auto-hébergé. Pas de cloud. Pas de VC.
|
|
||||||
</motion.p>
|
</motion.p>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* ═══ EMAIL CAPTURE — Bottom CTA ═══ */}
|
{/* ═══ EMAIL CAPTURE — Bottom CTA ═══ */}
|
||||||
<Section className="relative z-10 py-32 px-6" id="notify">
|
<Section className="relative z-10 py-32 px-6" id="notify" data-testid="launch-notify">
|
||||||
<div className="max-w-[600px] mx-auto text-center">
|
<div className="max-w-[600px] mx-auto text-center">
|
||||||
<motion.span
|
<motion.span
|
||||||
variants={inkReveal}
|
variants={inkReveal}
|
||||||
|
|
@ -534,7 +566,7 @@ export default function LandingPage() {
|
||||||
className="text-2xl sm:text-3xl mb-3 tracking-[0.05em]"
|
className="text-2xl sm:text-3xl mb-3 tracking-[0.05em]"
|
||||||
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 200 }}
|
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 200 }}
|
||||||
>
|
>
|
||||||
Rejoins les premiers
|
{t('landing.notify.title')}
|
||||||
</motion.h2>
|
</motion.h2>
|
||||||
|
|
||||||
<motion.div variants={brushStroke} className="w-16 h-px bg-gradient-to-r from-transparent via-[var(--sumi-kin)]/30 to-transparent mx-auto mb-6" />
|
<motion.div variants={brushStroke} className="w-16 h-px bg-gradient-to-r from-transparent via-[var(--sumi-kin)]/30 to-transparent mx-auto mb-6" />
|
||||||
|
|
@ -545,8 +577,7 @@ export default function LandingPage() {
|
||||||
className="text-sm text-[var(--sumi-text-secondary)] mb-10 leading-relaxed"
|
className="text-sm text-[var(--sumi-text-secondary)] mb-10 leading-relaxed"
|
||||||
style={{ fontFamily: 'var(--sumi-font-body)' }}
|
style={{ fontFamily: 'var(--sumi-font-body)' }}
|
||||||
>
|
>
|
||||||
Inscris-toi pour être notifié du lancement.
|
{t('landing.notify.desc')}
|
||||||
Pas de spam — un seul email le jour J.
|
|
||||||
</motion.p>
|
</motion.p>
|
||||||
|
|
||||||
<motion.form
|
<motion.form
|
||||||
|
|
@ -554,12 +585,14 @@ export default function LandingPage() {
|
||||||
custom={2}
|
custom={2}
|
||||||
onSubmit={handleSubscribe}
|
onSubmit={handleSubscribe}
|
||||||
className="flex flex-col sm:flex-row items-center gap-3 max-w-[460px] mx-auto"
|
className="flex flex-col sm:flex-row items-center gap-3 max-w-[460px] mx-auto"
|
||||||
|
data-testid="launch-notify-form"
|
||||||
>
|
>
|
||||||
{status === 'success' ? (
|
{status === 'success' ? (
|
||||||
<div className="flex items-center gap-2 text-[var(--sumi-sage)] text-sm py-3">
|
// BUG-12 fix: Use consistent success message from i18n
|
||||||
|
<div className="flex items-center gap-2 text-[var(--sumi-sage)] text-sm py-3" data-testid="launch-notify-success">
|
||||||
<Check size={16} />
|
<Check size={16} />
|
||||||
<span style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}>
|
<span style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}>
|
||||||
C'est noté. Merci.
|
{t('landing.form.successCta')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -568,23 +601,29 @@ export default function LandingPage() {
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="ton@email.com"
|
placeholder={t('landing.notify.placeholder')}
|
||||||
required
|
required
|
||||||
aria-label="Adresse email pour notification de lancement"
|
aria-label={t('landing.notify.ariaLabel')}
|
||||||
|
data-testid="launch-notify-email"
|
||||||
className="flex-1 w-full sm:w-auto px-4 py-2.5 bg-[var(--sumi-surface-card)] border border-[var(--sumi-border-default)] rounded-sm text-sm text-[var(--sumi-text-primary)] placeholder:text-[var(--sumi-text-disabled)] focus:outline-none focus:border-[var(--sumi-accent)]/50 focus:shadow-[var(--sumi-shadow-glow)] transition-all"
|
className="flex-1 w-full sm:w-auto px-4 py-2.5 bg-[var(--sumi-surface-card)] border border-[var(--sumi-border-default)] rounded-sm text-sm text-[var(--sumi-text-primary)] placeholder:text-[var(--sumi-text-disabled)] focus:outline-none focus:border-[var(--sumi-accent)]/50 focus:shadow-[var(--sumi-shadow-glow)] transition-all"
|
||||||
style={{ fontFamily: 'var(--sumi-font-body)' }}
|
style={{ fontFamily: 'var(--sumi-font-body)' }}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={status === 'loading'}
|
disabled={status === 'loading'}
|
||||||
|
data-testid="launch-notify-submit"
|
||||||
className="w-full sm:w-auto px-6 py-2.5 bg-[var(--sumi-accent)] text-[var(--sumi-text-inverse)] rounded-sm text-sm tracking-[0.1em] hover:bg-[var(--sumi-accent-hover)] disabled:opacity-50 transition-colors flex items-center justify-center gap-2"
|
className="w-full sm:w-auto px-6 py-2.5 bg-[var(--sumi-accent)] text-[var(--sumi-text-inverse)] rounded-sm text-sm tracking-[0.1em] hover:bg-[var(--sumi-accent-hover)] disabled:opacity-50 transition-colors flex items-center justify-center gap-2"
|
||||||
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 400 }}
|
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 400 }}
|
||||||
>
|
>
|
||||||
{status === 'loading' ? (
|
{status === 'loading' ? (
|
||||||
<span className="h-4 w-4 border border-current border-t-transparent rounded-full animate-spin" />
|
<span role="status">
|
||||||
|
<span className="h-4 w-4 border border-current border-t-transparent rounded-full animate-spin inline-block" aria-hidden="true" />
|
||||||
|
<span className="sr-only">{t('landing.form.loadingAriaLabel')}</span>
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
NOTIFIER MOI
|
{/* BUG-05 fix: Correct French imperative form via i18n */}
|
||||||
|
{t('landing.notify.submit')}
|
||||||
<ArrowRight size={14} />
|
<ArrowRight size={14} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -594,13 +633,13 @@ export default function LandingPage() {
|
||||||
</motion.form>
|
</motion.form>
|
||||||
|
|
||||||
{status === 'error' && (
|
{status === 'error' && (
|
||||||
<p className="text-xs text-[var(--sumi-vermillion)] mt-2">{errorMsg}</p>
|
<p className="text-xs text-[var(--sumi-vermillion)] mt-2" data-testid="launch-error-message">{errorMsg}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* ═══ FOOTER ═══ */}
|
{/* ═══ FOOTER ═══ */}
|
||||||
<footer className="relative z-10 border-t border-[var(--sumi-border-faint)] py-16 px-6">
|
<footer className="relative z-10 border-t border-[var(--sumi-border-faint)] py-16 px-6" data-testid="launch-footer">
|
||||||
<div className="max-w-[1000px] mx-auto">
|
<div className="max-w-[1000px] mx-auto">
|
||||||
<div className="flex flex-col md:flex-row items-center justify-between gap-8">
|
<div className="flex flex-col md:flex-row items-center justify-between gap-8">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
|
|
@ -616,16 +655,13 @@ export default function LandingPage() {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Links */}
|
{/* Links — BUG-08 fix: external links get target="_blank" rel="noopener noreferrer" */}
|
||||||
<nav className="flex items-center gap-8" aria-label="Footer navigation">
|
<nav className="flex items-center gap-8" aria-label="Footer navigation">
|
||||||
{[
|
{footerLinks.map((link) => (
|
||||||
{ label: 'Open Source', href: 'https://github.com/talas-audio' },
|
|
||||||
{ label: 'Confidentialité', href: '/privacy' },
|
|
||||||
{ label: 'Contact', href: 'mailto:contact@talas.audio' },
|
|
||||||
].map((link) => (
|
|
||||||
<a
|
<a
|
||||||
key={link.label}
|
key={link.label}
|
||||||
href={link.href}
|
href={link.href}
|
||||||
|
{...(link.external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
||||||
className="text-xs text-[var(--sumi-text-tertiary)] hover:text-[var(--sumi-text-secondary)] transition-colors tracking-[0.05em]"
|
className="text-xs text-[var(--sumi-text-tertiary)] hover:text-[var(--sumi-text-secondary)] transition-colors tracking-[0.05em]"
|
||||||
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
|
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
|
||||||
>
|
>
|
||||||
|
|
@ -641,11 +677,11 @@ export default function LandingPage() {
|
||||||
className="text-[10px] text-[var(--sumi-text-disabled)] tracking-[0.15em]"
|
className="text-[10px] text-[var(--sumi-text-disabled)] tracking-[0.15em]"
|
||||||
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
|
style={{ fontFamily: 'var(--sumi-font-heading)', fontWeight: 300 }}
|
||||||
>
|
>
|
||||||
TECHNOLOGIE AUDIO ÉTHIQUE — FAIT EN FRANCE — {new Date().getFullYear()}
|
{t('landing.footer.tagline')} — {new Date().getFullYear()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,17 @@
|
||||||
* Queue page — unified in features/library (Audit 2.1)
|
* Queue page — unified in features/library (Audit 2.1)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { QueueView } from '@/components/library/playlists/QueueView';
|
import { QueueView } from '@/components/library/playlists/QueueView';
|
||||||
|
|
||||||
export function QueuePage() {
|
export function QueuePage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = t('queue.pageTitle');
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
return <QueueView />;
|
return <QueueView />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,22 @@
|
||||||
import { EmptyState } from '@/components/ui/empty-state';
|
import { EmptyState } from '@/components/ui/empty-state';
|
||||||
import { FileAudio } from 'lucide-react';
|
import { FileAudio } from 'lucide-react';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
|
||||||
interface LibraryPageEmptyProps {
|
interface LibraryPageEmptyProps {
|
||||||
onUploadClick: () => void;
|
onUploadClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LibraryPageEmpty({ onUploadClick }: LibraryPageEmptyProps) {
|
export function LibraryPageEmpty({ onUploadClick }: LibraryPageEmptyProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
variant="centered"
|
variant="centered"
|
||||||
icon={<FileAudio className="w-full h-full" />}
|
icon={<FileAudio className="w-full h-full" />}
|
||||||
title="Your library is empty"
|
title={t('library.empty.title')}
|
||||||
description="Upload your first track or create a playlist to get started."
|
description={t('library.empty.description')}
|
||||||
action={{
|
action={{
|
||||||
label: 'Upload Track',
|
label: t('library.empty.uploadTrack'),
|
||||||
onClick: onUploadClick,
|
onClick: onUploadClick,
|
||||||
}}
|
}}
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { motion } from 'framer-motion';
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
import { Play, Clock, FileAudio, CheckCircle2, Circle } from 'lucide-react';
|
import { Play, Clock, FileAudio, CheckCircle2, Circle } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import type { Track } from '@/types';
|
import type { Track } from '@/types';
|
||||||
import { getArtistName, formatDuration, safeFormatDistanceToNow } from './utils';
|
import { getArtistName, formatDuration, safeFormatDistanceToNow } from './utils';
|
||||||
|
|
||||||
|
|
@ -21,8 +22,10 @@ export function LibraryPageGrid({
|
||||||
onToggleSelection,
|
onToggleSelection,
|
||||||
onPlayTrack,
|
onPlayTrack,
|
||||||
}: LibraryPageGridProps) {
|
}: LibraryPageGridProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label="Library tracks grid">
|
<section aria-label={t('library.grid.label')}>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-6"
|
className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-6"
|
||||||
variants={listVariants}
|
variants={listVariants}
|
||||||
|
|
@ -65,6 +68,7 @@ export function LibraryPageGrid({
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label={t('library.grid.play', { title: track.title })}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onPlayTrack(track);
|
onPlayTrack(track);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { Play, Download, Trash2, MoreVertical } from 'lucide-react';
|
import { Play, Download, Trash2, MoreVertical } from 'lucide-react';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import type { Track } from '@/types';
|
import type { Track } from '@/types';
|
||||||
import { getArtistName, formatDuration, safeFormatDistanceToNow } from './utils';
|
import { getArtistName, formatDuration, safeFormatDistanceToNow } from './utils';
|
||||||
|
|
||||||
|
|
@ -14,16 +15,18 @@ interface LibraryPageListProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LibraryPageList({ tracks, onPlayTrack }: LibraryPageListProps) {
|
export function LibraryPageList({ tracks, onPlayTrack }: LibraryPageListProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass rounded-2xl overflow-hidden shadow-lg">
|
<div className="glass rounded-2xl overflow-hidden shadow-lg">
|
||||||
<table className="w-full text-left text-sm">
|
<table className="w-full text-left text-sm" aria-label={t('library.table.label')}>
|
||||||
<thead className="bg-black/20 text-xs uppercase font-mono text-muted-foreground">
|
<thead className="bg-black/20 text-xs uppercase font-mono text-muted-foreground">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-4 w-12 text-center">#</th>
|
<th className="px-6 py-4 w-12 text-center">#</th>
|
||||||
<th className="px-6 py-4">Title</th>
|
<th className="px-6 py-4">{t('library.table.title')}</th>
|
||||||
<th className="px-6 py-4 hidden md:table-cell">Artist</th>
|
<th className="px-6 py-4 hidden md:table-cell">{t('library.table.artist')}</th>
|
||||||
<th className="px-6 py-4 hidden sm:table-cell">Date</th>
|
<th className="px-6 py-4 hidden sm:table-cell">{t('library.table.date')}</th>
|
||||||
<th className="px-6 py-4 text-right">Duration</th>
|
<th className="px-6 py-4 text-right">{t('library.table.duration')}</th>
|
||||||
<th className="px-6 py-4 w-12" />
|
<th className="px-6 py-4 w-12" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
@ -57,6 +60,7 @@ export function LibraryPageList({ tracks, onPlayTrack }: LibraryPageListProps) {
|
||||||
type="button"
|
type="button"
|
||||||
className="p-2 hover:bg-white/10 rounded-full transition-colors duration-[var(--duration-fast)] opacity-0 group-hover:opacity-100 text-muted-foreground"
|
className="p-2 hover:bg-white/10 rounded-full transition-colors duration-[var(--duration-fast)] opacity-0 group-hover:opacity-100 text-muted-foreground"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
aria-label={t('library.table.moreOptions', { title: track.title })}
|
||||||
>
|
>
|
||||||
<MoreVertical className="w-4 h-4" />
|
<MoreVertical className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -66,10 +70,10 @@ export function LibraryPageList({ tracks, onPlayTrack }: LibraryPageListProps) {
|
||||||
className="bg-background/90 backdrop-blur-xl border-border"
|
className="bg-background/90 backdrop-blur-xl border-border"
|
||||||
>
|
>
|
||||||
<DropdownMenuItem className="cursor-pointer gap-2 focus:bg-primary/20">
|
<DropdownMenuItem className="cursor-pointer gap-2 focus:bg-primary/20">
|
||||||
<Download className="w-4 h-4" /> Download
|
<Download className="w-4 h-4" /> {t('library.table.download')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem className="cursor-pointer gap-2 text-destructive focus:bg-destructive/10 focus:text-destructive">
|
<DropdownMenuItem className="cursor-pointer gap-2 text-destructive focus:bg-destructive/10 focus:text-destructive">
|
||||||
<Trash2 className="w-4 h-4" /> Delete
|
<Trash2 className="w-4 h-4" /> {t('library.table.delete')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Search, Grid, List as ListIcon, Plus } from 'lucide-react';
|
import { Search, Grid, List as ListIcon, Plus } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import type { LibraryPageViewMode } from './types';
|
import type { LibraryPageViewMode } from './types';
|
||||||
|
|
||||||
interface LibraryPageToolbarProps {
|
interface LibraryPageToolbarProps {
|
||||||
|
|
@ -19,14 +20,16 @@ export function LibraryPageToolbar({
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onNewClick,
|
onNewClick,
|
||||||
}: LibraryPageToolbarProps) {
|
}: LibraryPageToolbarProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sticky top-0 z-20 bg-background/80 backdrop-blur-xl border-b border-border py-4 -mx-4 px-4 md:-mx-8 md:px-8 flex flex-col md:flex-row gap-4 justify-between items-center transition-all">
|
<div className="sticky top-0 z-20 bg-background/80 backdrop-blur-xl border-b border-border py-4 -mx-4 px-4 md:-mx-8 md:px-8 flex flex-col md:flex-row gap-4 justify-between items-center transition-all">
|
||||||
<div className="flex items-center gap-4 w-full md:w-auto">
|
<div className="flex items-center gap-4 w-full md:w-auto">
|
||||||
<h1 className="text-heading-2 font-heading text-foreground hidden md:block">Library</h1>
|
<h1 className="text-heading-2 font-heading text-foreground hidden md:block">{t('library.title')}</h1>
|
||||||
<div className="relative flex-1 md:w-80">
|
<div className="relative flex-1 md:w-80">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search..."
|
placeholder={t('library.searchPlaceholder')}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
className="pl-9 bg-muted/20 border-transparent focus:bg-background focus:ring-1 focus:ring-primary/50 transition-all rounded-xl"
|
className="pl-9 bg-muted/20 border-transparent focus:bg-background focus:ring-1 focus:ring-primary/50 transition-all rounded-xl"
|
||||||
|
|
@ -42,7 +45,7 @@ export function LibraryPageToolbar({
|
||||||
'h-8 w-8 flex items-center justify-center rounded-md transition-all',
|
'h-8 w-8 flex items-center justify-center rounded-md transition-all',
|
||||||
viewMode === 'grid' ? 'bg-background text-primary shadow-sm' : 'text-muted-foreground hover:text-foreground'
|
viewMode === 'grid' ? 'bg-background text-primary shadow-sm' : 'text-muted-foreground hover:text-foreground'
|
||||||
)}
|
)}
|
||||||
aria-label="Grid view"
|
aria-label={t('library.viewMode.grid')}
|
||||||
>
|
>
|
||||||
<Grid className="w-4 h-4" />
|
<Grid className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -53,7 +56,7 @@ export function LibraryPageToolbar({
|
||||||
'h-8 w-8 flex items-center justify-center rounded-md transition-all',
|
'h-8 w-8 flex items-center justify-center rounded-md transition-all',
|
||||||
viewMode === 'list' ? 'bg-background text-primary shadow-sm' : 'text-muted-foreground hover:text-foreground'
|
viewMode === 'list' ? 'bg-background text-primary shadow-sm' : 'text-muted-foreground hover:text-foreground'
|
||||||
)}
|
)}
|
||||||
aria-label="List view"
|
aria-label={t('library.viewMode.list')}
|
||||||
>
|
>
|
||||||
<ListIcon className="w-4 h-4" />
|
<ListIcon className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -62,7 +65,7 @@ export function LibraryPageToolbar({
|
||||||
onClick={onNewClick}
|
onClick={onNewClick}
|
||||||
className="shadow-sm transition-all bg-primary text-primary-foreground"
|
className="shadow-sm transition-all bg-primary text-primary-foreground"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-2" /> New
|
<Plus className="w-4 h-4 mr-2" /> {t('library.new')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { fn } from '@storybook/test';
|
import { fn } from 'storybook/test';
|
||||||
import { PlaybackSpeedControl } from './PlaybackSpeedControl';
|
import { PlaybackSpeedControl } from './PlaybackSpeedControl';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ export function PlayerControls({
|
||||||
<Tooltip content="Shuffle">
|
<Tooltip content="Shuffle">
|
||||||
<button
|
<button
|
||||||
onClick={onShuffle}
|
onClick={onShuffle}
|
||||||
|
aria-label={shuffle ? "Disable shuffle" : "Enable shuffle"}
|
||||||
|
aria-pressed={shuffle}
|
||||||
className={cn(
|
className={cn(
|
||||||
iconBtnClass,
|
iconBtnClass,
|
||||||
size,
|
size,
|
||||||
|
|
@ -55,6 +57,7 @@ export function PlayerControls({
|
||||||
|
|
||||||
<button
|
<button
|
||||||
data-testid="prev-button"
|
data-testid="prev-button"
|
||||||
|
aria-label="Previous track"
|
||||||
onClick={onPrevious}
|
onClick={onPrevious}
|
||||||
className={cn(iconBtnClass, size, "text-foreground hover:text-primary hover:bg-white/5")}
|
className={cn(iconBtnClass, size, "text-foreground hover:text-primary hover:bg-white/5")}
|
||||||
>
|
>
|
||||||
|
|
@ -63,6 +66,7 @@ export function PlayerControls({
|
||||||
|
|
||||||
<button
|
<button
|
||||||
data-testid="play-button"
|
data-testid="play-button"
|
||||||
|
aria-label={isPlaying ? "Pause" : "Play"}
|
||||||
onClick={onPlayPause}
|
onClick={onPlayPause}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-center rounded-full bg-primary text-primary-foreground flex-shrink-0 active:scale-95 transition-all shadow-sm",
|
"flex items-center justify-center rounded-full bg-primary text-primary-foreground flex-shrink-0 active:scale-95 transition-all shadow-sm",
|
||||||
|
|
@ -78,6 +82,7 @@ export function PlayerControls({
|
||||||
|
|
||||||
<button
|
<button
|
||||||
data-testid="next-button"
|
data-testid="next-button"
|
||||||
|
aria-label="Next track"
|
||||||
onClick={onNext}
|
onClick={onNext}
|
||||||
className={cn(iconBtnClass, size, "text-foreground hover:text-primary hover:bg-white/5")}
|
className={cn(iconBtnClass, size, "text-foreground hover:text-primary hover:bg-white/5")}
|
||||||
>
|
>
|
||||||
|
|
@ -87,6 +92,8 @@ export function PlayerControls({
|
||||||
<Tooltip content="Repeat">
|
<Tooltip content="Repeat">
|
||||||
<button
|
<button
|
||||||
onClick={onRepeat}
|
onClick={onRepeat}
|
||||||
|
aria-label={repeat === 'off' ? 'Enable repeat' : repeat === 'track' ? 'Repeat track' : 'Repeat playlist'}
|
||||||
|
aria-pressed={repeat !== 'off'}
|
||||||
className={cn(
|
className={cn(
|
||||||
iconBtnClass,
|
iconBtnClass,
|
||||||
size,
|
size,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { fn } from '@storybook/test';
|
import { fn } from 'storybook/test';
|
||||||
import { PlayerError } from './PlayerError';
|
import { PlayerError } from './PlayerError';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { QualitySelector } from './QualitySelector';
|
import { QualitySelector } from './QualitySelector';
|
||||||
import { useArgs } from '@storybook/preview-api';
|
import { useArgs } from 'storybook/preview-api';
|
||||||
|
|
||||||
const meta: Meta = {
|
const meta: Meta = {
|
||||||
title: 'Components/Features/Player/QualitySelector',
|
title: 'Components/Features/Player/QualitySelector',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { RepeatShuffleButtons } from './RepeatShuffleButtons';
|
import { RepeatShuffleButtons } from './RepeatShuffleButtons';
|
||||||
import { useArgs } from '@storybook/preview-api';
|
import { useArgs } from 'storybook/preview-api';
|
||||||
|
|
||||||
const meta: Meta = {
|
const meta: Meta = {
|
||||||
title: 'Components/Features/Player/RepeatShuffleButtons',
|
title: 'Components/Features/Player/RepeatShuffleButtons',
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,9 @@ export function usePlayer(
|
||||||
|
|
||||||
// Callback pour les erreurs (Invalid URI, réseau, etc.)
|
// Callback pour les erreurs (Invalid URI, réseau, etc.)
|
||||||
audioPlayerService.onError((error) => {
|
audioPlayerService.onError((error) => {
|
||||||
|
// Ignore errors when no track is loaded (e.g. on initial page load)
|
||||||
|
if (!store.currentTrack?.url) return;
|
||||||
|
|
||||||
const msg = error instanceof Error ? error.message : String(error);
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
// Try fallback to direct audio download URL before giving up
|
// Try fallback to direct audio download URL before giving up
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { fn } from '@storybook/test';
|
import { fn } from 'storybook/test';
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import {
|
import {
|
||||||
AddCollaboratorModal,
|
AddCollaboratorModal,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { AddTrackToPlaylistModal, AddTrackToPlaylistModalSkeleton } from './AddTrackToPlaylistModal';
|
import { AddTrackToPlaylistModal, AddTrackToPlaylistModalSkeleton } from './AddTrackToPlaylistModal';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useArgs } from '@storybook/preview-api';
|
import { useArgs } from 'storybook/preview-api';
|
||||||
|
|
||||||
const meta: Meta<typeof AddTrackToPlaylistModal> = {
|
const meta: Meta<typeof AddTrackToPlaylistModal> = {
|
||||||
title: 'Components/Features/Playlists/AddTrackToPlaylistModal',
|
title: 'Components/Features/Playlists/AddTrackToPlaylistModal',
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
||||||
import { Copy } from 'lucide-react';
|
import { Copy } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { duplicatePlaylist } from '../services/playlistService';
|
import { duplicatePlaylist } from '../services/playlistService';
|
||||||
import type { Playlist } from '../types';
|
import type { Playlist } from '../types';
|
||||||
|
|
||||||
|
|
@ -26,6 +27,7 @@ export const DuplicatePlaylistButton: React.FC<DuplicatePlaylistButtonProps> = (
|
||||||
size = 'sm',
|
size = 'sm',
|
||||||
className,
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [isDuplicating, setIsDuplicating] = useState(false);
|
const [isDuplicating, setIsDuplicating] = useState(false);
|
||||||
const { success: showSuccess, error: showError } = useToast();
|
const { success: showSuccess, error: showError } = useToast();
|
||||||
|
|
||||||
|
|
@ -33,15 +35,15 @@ export const DuplicatePlaylistButton: React.FC<DuplicatePlaylistButtonProps> = (
|
||||||
setIsDuplicating(true);
|
setIsDuplicating(true);
|
||||||
try {
|
try {
|
||||||
const newPlaylist = await duplicatePlaylist(playlistId, {
|
const newPlaylist = await duplicatePlaylist(playlistId, {
|
||||||
new_title: `${playlistTitle} (copie)`,
|
new_title: t('playlists.duplicate.copySuffix', { title: playlistTitle }),
|
||||||
});
|
});
|
||||||
showSuccess('Playlist dupliquée avec succès');
|
showSuccess(t('playlists.duplicate.success'));
|
||||||
onSuccess?.(newPlaylist);
|
onSuccess?.(newPlaylist);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(
|
showError(
|
||||||
err instanceof Error
|
err instanceof Error
|
||||||
? err.message
|
? err.message
|
||||||
: 'Erreur lors de la duplication de la playlist',
|
: t('playlists.duplicate.error'),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsDuplicating(false);
|
setIsDuplicating(false);
|
||||||
|
|
@ -55,10 +57,10 @@ export const DuplicatePlaylistButton: React.FC<DuplicatePlaylistButtonProps> = (
|
||||||
onClick={handleDuplicate}
|
onClick={handleDuplicate}
|
||||||
disabled={isDuplicating}
|
disabled={isDuplicating}
|
||||||
className={`gap-2 ${className ?? ''}`}
|
className={`gap-2 ${className ?? ''}`}
|
||||||
aria-label="Dupliquer la playlist"
|
aria-label={t('playlists.duplicate.ariaLabel')}
|
||||||
>
|
>
|
||||||
<Copy className="h-4 w-4" aria-hidden />
|
<Copy className="h-4 w-4" aria-hidden />
|
||||||
{isDuplicating ? 'Duplication...' : 'Dupliquer'}
|
{isDuplicating ? t('playlists.duplicate.duplicating') : t('playlists.duplicate.button')}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { TokenStorage } from '@/services/tokenStorage';
|
import { TokenStorage } from '@/services/tokenStorage';
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/logger';
|
||||||
|
|
||||||
|
|
@ -26,6 +27,7 @@ export const ExportPlaylistButton: React.FC<ExportPlaylistButtonProps> = ({
|
||||||
playlistId,
|
playlistId,
|
||||||
playlistTitle = 'playlist',
|
playlistTitle = 'playlist',
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [isExporting, setIsExporting] = useState(false);
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
const { success: showSuccess, error: showError } = useToast();
|
const { success: showSuccess, error: showError } = useToast();
|
||||||
|
|
||||||
|
|
@ -37,7 +39,7 @@ export const ExportPlaylistButton: React.FC<ExportPlaylistButtonProps> = ({
|
||||||
// Récupérer le token d'authentification
|
// Récupérer le token d'authentification
|
||||||
const token = TokenStorage.getAccessToken();
|
const token = TokenStorage.getAccessToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
showError('Vous devez être connecté pour exporter une playlist');
|
showError(t('playlists.export.authRequired'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,14 +53,12 @@ export const ExportPlaylistButton: React.FC<ExportPlaylistButtonProps> = ({
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status === 403) {
|
if (response.status === 403) {
|
||||||
throw new Error(
|
throw new Error(t('playlists.export.forbidden'));
|
||||||
"Vous n'avez pas la permission d'exporter cette playlist",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
throw new Error('Playlist non trouvée');
|
throw new Error(t('playlists.export.notFound'));
|
||||||
}
|
}
|
||||||
throw new Error("Erreur lors de l'export");
|
throw new Error(t('playlists.export.error'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer le blob
|
// Récupérer le blob
|
||||||
|
|
@ -85,15 +85,13 @@ export const ExportPlaylistButton: React.FC<ExportPlaylistButtonProps> = ({
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
window.URL.revokeObjectURL(downloadUrl);
|
window.URL.revokeObjectURL(downloadUrl);
|
||||||
|
|
||||||
showSuccess(
|
showSuccess(t('playlists.export.success', { format: format.toUpperCase() }));
|
||||||
`La playlist a été exportée en format ${format.toUpperCase()}`,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Export error:', { error });
|
logger.error('Export error:', { error });
|
||||||
showError(
|
showError(
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: "Une erreur est survenue lors de l'export",
|
: t('playlists.export.genericError'),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsExporting(false);
|
setIsExporting(false);
|
||||||
|
|
@ -110,7 +108,7 @@ export const ExportPlaylistButton: React.FC<ExportPlaylistButtonProps> = ({
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
{isExporting ? 'Export...' : 'Exporter'}
|
{isExporting ? t('playlists.export.exporting') : t('playlists.export.button')}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
|
|
@ -118,19 +116,19 @@ export const ExportPlaylistButton: React.FC<ExportPlaylistButtonProps> = ({
|
||||||
onClick={() => handleExport('json')}
|
onClick={() => handleExport('json')}
|
||||||
disabled={isExporting}
|
disabled={isExporting}
|
||||||
>
|
>
|
||||||
Exporter en JSON
|
{t('playlists.export.json')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => handleExport('csv')}
|
onClick={() => handleExport('csv')}
|
||||||
disabled={isExporting}
|
disabled={isExporting}
|
||||||
>
|
>
|
||||||
Exporter en CSV
|
{t('playlists.export.csv')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => handleExport('m3u')}
|
onClick={() => handleExport('m3u')}
|
||||||
disabled={isExporting}
|
disabled={isExporting}
|
||||||
>
|
>
|
||||||
Exporter en M3U
|
{t('playlists.export.m3u')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { Dialog } from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/logger';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { playlistsApi } from '@/services/api/playlists';
|
import { playlistsApi } from '@/services/api/playlists';
|
||||||
|
|
@ -23,6 +24,7 @@ export const ImportPlaylistButton: React.FC<ImportPlaylistButtonProps> = ({
|
||||||
onImported,
|
onImported,
|
||||||
className,
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isImporting, setIsImporting] = useState(false);
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
|
@ -175,13 +177,13 @@ export const ImportPlaylistButton: React.FC<ImportPlaylistButtonProps> = ({
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
>
|
>
|
||||||
<Upload className="h-4 w-4 mr-2" />
|
<Upload className="h-4 w-4 mr-2" />
|
||||||
Importer
|
{t('playlists.importButton')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
title="Importer une playlist"
|
title={t('playlists.importButton')}
|
||||||
footer={footer}
|
footer={footer}
|
||||||
>
|
>
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
} from '../services/playlistService';
|
} from '../services/playlistService';
|
||||||
import type { Playlist } from '../types';
|
import type { Playlist } from '../types';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { useUser } from '@/features/auth/hooks/useUser';
|
import { useUser } from '@/features/auth/hooks/useUser';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -39,6 +40,7 @@ export function PlaylistFollowButton({
|
||||||
variant,
|
variant,
|
||||||
showCount = false,
|
showCount = false,
|
||||||
}: PlaylistFollowButtonProps) {
|
}: PlaylistFollowButtonProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data: user } = useUser();
|
const { data: user } = useUser();
|
||||||
const { success: showSuccess, error: showError } = useToast();
|
const { success: showSuccess, error: showError } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
@ -90,7 +92,7 @@ export function PlaylistFollowButton({
|
||||||
setIsUpdating(true);
|
setIsUpdating(true);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
showSuccess('Vous suivez maintenant cette playlist');
|
showSuccess(t('playlists.followBtn.followSuccess'));
|
||||||
onFollowChange?.(true);
|
onFollowChange?.(true);
|
||||||
// Invalidate queries to refresh data
|
// Invalidate queries to refresh data
|
||||||
queryClient.invalidateQueries({ queryKey: ['playlist', playlistId] });
|
queryClient.invalidateQueries({ queryKey: ['playlist', playlistId] });
|
||||||
|
|
@ -108,7 +110,7 @@ export function PlaylistFollowButton({
|
||||||
err.response?.data?.error?.message ||
|
err.response?.data?.error?.message ||
|
||||||
err.response?.data?.message ||
|
err.response?.data?.message ||
|
||||||
err.message ||
|
err.message ||
|
||||||
"Erreur lors de l'abonnement à la playlist";
|
t('playlists.followBtn.followError');
|
||||||
showError(errorMessage);
|
showError(errorMessage);
|
||||||
},
|
},
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
|
|
@ -126,7 +128,7 @@ export function PlaylistFollowButton({
|
||||||
setIsUpdating(true);
|
setIsUpdating(true);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
showSuccess('Vous ne suivez plus cette playlist');
|
showSuccess(t('playlists.followBtn.unfollowSuccess'));
|
||||||
onFollowChange?.(false);
|
onFollowChange?.(false);
|
||||||
// Invalidate queries to refresh data
|
// Invalidate queries to refresh data
|
||||||
queryClient.invalidateQueries({ queryKey: ['playlist', playlistId] });
|
queryClient.invalidateQueries({ queryKey: ['playlist', playlistId] });
|
||||||
|
|
@ -144,7 +146,7 @@ export function PlaylistFollowButton({
|
||||||
err.response?.data?.error?.message ||
|
err.response?.data?.error?.message ||
|
||||||
err.response?.data?.message ||
|
err.response?.data?.message ||
|
||||||
err.message ||
|
err.message ||
|
||||||
'Erreur lors du désabonnement de la playlist';
|
t('playlists.followBtn.unfollowError');
|
||||||
showError(errorMessage);
|
showError(errorMessage);
|
||||||
},
|
},
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
|
|
@ -183,12 +185,12 @@ export function PlaylistFollowButton({
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<LoadingSpinner size="sm" inline className="mr-2" />
|
<LoadingSpinner size="sm" inline className="mr-2" />
|
||||||
{following ? 'Désabonnement...' : 'Abonnement...'}
|
{following ? t('playlists.followBtn.unfollowing') : t('playlists.followBtn.subscribing')}
|
||||||
</>
|
</>
|
||||||
) : following ? (
|
) : following ? (
|
||||||
<>
|
<>
|
||||||
<UserCheck className="h-4 w-4 mr-2" />
|
<UserCheck className="h-4 w-4 mr-2" />
|
||||||
Abonné
|
{t('playlists.followBtn.following')}
|
||||||
{showCount && followerCount > 0 && (
|
{showCount && followerCount > 0 && (
|
||||||
<span className="ml-2 text-xs">({followerCount})</span>
|
<span className="ml-2 text-xs">({followerCount})</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -196,7 +198,7 @@ export function PlaylistFollowButton({
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<UserPlus className="h-4 w-4 mr-2" />
|
<UserPlus className="h-4 w-4 mr-2" />
|
||||||
Suivre
|
{t('playlists.followBtn.follow')}
|
||||||
{showCount && followerCount > 0 && (
|
{showCount && followerCount > 0 && (
|
||||||
<span className="ml-2 text-xs">({followerCount})</span>
|
<span className="ml-2 text-xs">({followerCount})</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
* T0461: Create Playlist Create/Edit Form
|
* T0461: Create Playlist Create/Edit Form
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
@ -14,6 +15,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||||
import { useCreatePlaylist, useUpdatePlaylist } from '../hooks/usePlaylist';
|
import { useCreatePlaylist, useUpdatePlaylist } from '../hooks/usePlaylist';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { useIsRateLimited } from '@/hooks/useIsRateLimited';
|
import { useIsRateLimited } from '@/hooks/useIsRateLimited';
|
||||||
import type {
|
import type {
|
||||||
Playlist,
|
Playlist,
|
||||||
|
|
@ -21,42 +23,12 @@ import type {
|
||||||
UpdatePlaylistRequest,
|
UpdatePlaylistRequest,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
// Schéma de validation
|
type PlaylistFormData = {
|
||||||
const playlistFormSchema = z.object({
|
title: string;
|
||||||
title: z
|
description?: string;
|
||||||
.string()
|
is_public: boolean;
|
||||||
.min(1, 'Le titre est requis')
|
cover_url?: string;
|
||||||
.max(200, 'Le titre ne peut pas dépasser 200 caractères'),
|
};
|
||||||
description: z
|
|
||||||
.string()
|
|
||||||
.max(2000, 'La description ne peut pas dépasser 2000 caractères')
|
|
||||||
.optional()
|
|
||||||
.or(z.literal('')),
|
|
||||||
is_public: z.boolean().default(true),
|
|
||||||
cover_url: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.refine((val) => !val || val === '' || val.length <= 500, {
|
|
||||||
message: "L'URL ne peut pas dépasser 500 caractères",
|
|
||||||
})
|
|
||||||
.refine(
|
|
||||||
(val) => {
|
|
||||||
if (!val || val === '') return true;
|
|
||||||
try {
|
|
||||||
new URL(val);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: "L'URL de la couverture doit être valide",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.transform((val) => (val === '' ? undefined : val)),
|
|
||||||
});
|
|
||||||
|
|
||||||
type PlaylistFormData = z.infer<typeof playlistFormSchema>;
|
|
||||||
|
|
||||||
interface PlaylistFormProps {
|
interface PlaylistFormProps {
|
||||||
playlist?: Playlist;
|
playlist?: Playlist;
|
||||||
|
|
@ -75,6 +47,7 @@ export function PlaylistForm({
|
||||||
submitLabel,
|
submitLabel,
|
||||||
className,
|
className,
|
||||||
}: PlaylistFormProps) {
|
}: PlaylistFormProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { success: showSuccess, error: showError } = useToast();
|
const { success: showSuccess, error: showError } = useToast();
|
||||||
const createMutation = useCreatePlaylist();
|
const createMutation = useCreatePlaylist();
|
||||||
const updateMutation = useUpdatePlaylist();
|
const updateMutation = useUpdatePlaylist();
|
||||||
|
|
@ -83,6 +56,44 @@ export function PlaylistForm({
|
||||||
const isEditMode = !!playlist;
|
const isEditMode = !!playlist;
|
||||||
const isSubmitting = createMutation.isPending || updateMutation.isPending;
|
const isSubmitting = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
|
const playlistFormSchema = useMemo(
|
||||||
|
() =>
|
||||||
|
z.object({
|
||||||
|
title: z
|
||||||
|
.string()
|
||||||
|
.min(1, t('playlists.form.titleRequired'))
|
||||||
|
.max(200, t('playlists.form.titleMaxLength')),
|
||||||
|
description: z
|
||||||
|
.string()
|
||||||
|
.max(2000, t('playlists.form.descriptionMaxLength'))
|
||||||
|
.optional()
|
||||||
|
.or(z.literal('')),
|
||||||
|
is_public: z.boolean().default(true),
|
||||||
|
cover_url: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.refine((val) => !val || val === '' || val.length <= 500, {
|
||||||
|
message: t('playlists.form.coverUrlMaxLength'),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(val) => {
|
||||||
|
if (!val || val === '') return true;
|
||||||
|
try {
|
||||||
|
new URL(val);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: t('playlists.form.coverUrlInvalid'),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.transform((val) => (val === '' ? undefined : val)),
|
||||||
|
}),
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
|
@ -116,14 +127,14 @@ export function PlaylistForm({
|
||||||
id: playlist.id,
|
id: playlist.id,
|
||||||
data: formData as UpdatePlaylistRequest,
|
data: formData as UpdatePlaylistRequest,
|
||||||
});
|
});
|
||||||
showSuccess('Playlist mise à jour avec succès');
|
showSuccess(t('playlists.form.updateSuccess'));
|
||||||
} else {
|
} else {
|
||||||
await createMutation.mutateAsync(formData as CreatePlaylistRequest);
|
await createMutation.mutateAsync(formData as CreatePlaylistRequest);
|
||||||
showSuccess('Playlist créée avec succès');
|
showSuccess(t('playlists.form.createSuccess'));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(
|
showError(
|
||||||
error instanceof Error ? error.message : 'Une erreur est survenue',
|
error instanceof Error ? error.message : t('playlists.form.genericError'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -134,15 +145,15 @@ export function PlaylistForm({
|
||||||
className={className}
|
className={className}
|
||||||
aria-label={
|
aria-label={
|
||||||
isEditMode
|
isEditMode
|
||||||
? 'Formulaire de modification de playlist'
|
? t('playlists.form.editAriaLabel')
|
||||||
: 'Formulaire de création de playlist'
|
: t('playlists.form.createAriaLabel')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="title">
|
<Label htmlFor="title">
|
||||||
Titre{' '}
|
{t('playlists.form.titleLabel')}{' '}
|
||||||
<span className="text-destructive" aria-hidden="true">
|
<span className="text-destructive" aria-hidden="true">
|
||||||
*
|
*
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -150,7 +161,7 @@ export function PlaylistForm({
|
||||||
<Input
|
<Input
|
||||||
id="title"
|
id="title"
|
||||||
{...register('title')}
|
{...register('title')}
|
||||||
placeholder="Ma playlist"
|
placeholder={t('playlists.form.titlePlaceholder')}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
aria-invalid={errors.title ? 'true' : 'false'}
|
aria-invalid={errors.title ? 'true' : 'false'}
|
||||||
aria-describedby={errors.title ? 'title-error' : undefined}
|
aria-describedby={errors.title ? 'title-error' : undefined}
|
||||||
|
|
@ -169,11 +180,11 @@ export function PlaylistForm({
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="description">Description</Label>
|
<Label htmlFor="description">{t('playlists.form.descriptionLabel')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="description"
|
id="description"
|
||||||
{...register('description')}
|
{...register('description')}
|
||||||
placeholder="Décrivez votre playlist..."
|
placeholder={t('playlists.form.descriptionPlaceholder')}
|
||||||
rows={4}
|
rows={4}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
aria-invalid={errors.description ? 'true' : 'false'}
|
aria-invalid={errors.description ? 'true' : 'false'}
|
||||||
|
|
@ -194,7 +205,7 @@ export function PlaylistForm({
|
||||||
|
|
||||||
{/* Cover URL */}
|
{/* Cover URL */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="cover_url">URL de la couverture</Label>
|
<Label htmlFor="cover_url">{t('playlists.form.coverUrlLabel')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="cover_url"
|
id="cover_url"
|
||||||
type="url"
|
type="url"
|
||||||
|
|
@ -225,7 +236,7 @@ export function PlaylistForm({
|
||||||
aria-checked={isPublic}
|
aria-checked={isPublic}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="is_public" className="cursor-pointer">
|
<Label htmlFor="is_public" className="cursor-pointer">
|
||||||
Playlist publique
|
{t('playlists.form.isPublic')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -237,9 +248,9 @@ export function PlaylistForm({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
aria-label="Annuler la modification"
|
aria-label={t('playlists.form.cancelEdit')}
|
||||||
>
|
>
|
||||||
Annuler
|
{t('playlists.form.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -248,12 +259,12 @@ export function PlaylistForm({
|
||||||
aria-label={
|
aria-label={
|
||||||
submitLabel ||
|
submitLabel ||
|
||||||
(isEditMode
|
(isEditMode
|
||||||
? 'Enregistrer les modifications'
|
? t('playlists.form.saveChanges')
|
||||||
: 'Créer la playlist')
|
: t('playlists.form.createPlaylist'))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isSubmitting && <LoadingSpinner size="sm" inline className="mr-2" />}
|
{isSubmitting && <LoadingSpinner size="sm" inline className="mr-2" />}
|
||||||
{submitLabel || (isEditMode ? 'Enregistrer' : 'Créer')}
|
{submitLabel || (isEditMode ? t('playlists.form.save') : t('playlists.form.create'))}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Play, Pause, Music, GripVertical } from 'lucide-react';
|
import { Play, Pause, Music, GripVertical } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { RemoveTrackButton } from './RemoveTrackButton';
|
import { RemoveTrackButton } from './RemoveTrackButton';
|
||||||
import { AddToFavorisButton } from './AddToFavorisButton';
|
import { AddToFavorisButton } from './AddToFavorisButton';
|
||||||
import type { PlaylistTrack, Track } from '../types';
|
import type { PlaylistTrack, Track } from '../types';
|
||||||
|
|
@ -52,6 +53,7 @@ export function PlaylistTrackItem({
|
||||||
canRemoveTracks = true,
|
canRemoveTracks = true,
|
||||||
isFavorisPlaylist = false,
|
isFavorisPlaylist = false,
|
||||||
}: PlaylistTrackItemProps) {
|
}: PlaylistTrackItemProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
|
|
@ -77,14 +79,14 @@ export function PlaylistTrackItem({
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
role="listitem"
|
role="listitem"
|
||||||
aria-label={`Piste ${position}: ${track.title}`}
|
aria-label={t('playlists.trackItem', { position: position + 1, title: track.title })}
|
||||||
>
|
>
|
||||||
{/* Handle de drag (si drag-and-drop est activé) */}
|
{/* Handle de drag (si drag-and-drop est activé) */}
|
||||||
{dragHandleProps && (
|
{dragHandleProps && (
|
||||||
<div
|
<div
|
||||||
{...dragHandleProps}
|
{...dragHandleProps}
|
||||||
className="flex-shrink-0 cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground transition-colors duration-[var(--duration-fast)]"
|
className="flex-shrink-0 cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground transition-colors duration-[var(--duration-fast)]"
|
||||||
aria-label="Réorganiser"
|
aria-label={t('playlists.detail.reorder')}
|
||||||
>
|
>
|
||||||
<GripVertical className="h-5 w-5" />
|
<GripVertical className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -99,8 +101,8 @@ export function PlaylistTrackItem({
|
||||||
className="p-1 rounded-full hover:bg-muted dark:hover:bg-muted active:bg-muted dark:active:bg-muted transition-colors duration-[var(--duration-fast)] touch-manipulation min-h-8 min-w-8 sm:min-h-0 sm:min-w-0"
|
className="p-1 rounded-full hover:bg-muted dark:hover:bg-muted active:bg-muted dark:active:bg-muted transition-colors duration-[var(--duration-fast)] touch-manipulation min-h-8 min-w-8 sm:min-h-0 sm:min-w-0"
|
||||||
aria-label={
|
aria-label={
|
||||||
isPlaying
|
isPlaying
|
||||||
? `Mettre en pause ${track.title}`
|
? t('playlists.detail.pauseTrack', { title: track.title })
|
||||||
: `Lire ${track.title}`
|
: t('playlists.detail.playTrack', { title: track.title })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isPlaying ? (
|
{isPlaying ? (
|
||||||
|
|
@ -110,7 +112,7 @@ export function PlaylistTrackItem({
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<span>{position}</span>
|
<span>{position + 1}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -119,7 +121,7 @@ export function PlaylistTrackItem({
|
||||||
{track.cover_art_path ? (
|
{track.cover_art_path ? (
|
||||||
<img
|
<img
|
||||||
src={track.cover_art_path}
|
src={track.cover_art_path}
|
||||||
alt={`Cover de ${track.title}`}
|
alt={t('playlists.detail.coverAlt', { title: track.title })}
|
||||||
className="w-10 h-10 sm:w-12 sm:h-12 rounded-md object-cover"
|
className="w-10 h-10 sm:w-12 sm:h-12 rounded-md object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -150,7 +152,7 @@ export function PlaylistTrackItem({
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
{!isFavorisPlaylist && isHovered && (
|
{!isFavorisPlaylist && isHovered && (
|
||||||
<AddToFavorisButton trackId={track.id} aria-label={`Add ${track.title} to Favorites`} />
|
<AddToFavorisButton trackId={track.id} aria-label={t('playlists.detail.addToFavorites', { title: track.title })} />
|
||||||
)}
|
)}
|
||||||
{isHovered && canRemoveTracks && onTrackRemoved && (
|
{isHovered && canRemoveTracks && onTrackRemoved && (
|
||||||
<RemoveTrackButton
|
<RemoveTrackButton
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { fn } from '@storybook/test';
|
import { fn } from 'storybook/test';
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import {
|
import {
|
||||||
SharePlaylistModal,
|
SharePlaylistModal,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import type { CreatePlaylistFormData } from './schema';
|
import type { CreatePlaylistFormData } from './schema';
|
||||||
|
|
||||||
interface CreatePlaylistDialogFormProps {
|
interface CreatePlaylistDialogFormProps {
|
||||||
|
|
@ -31,21 +32,22 @@ export function CreatePlaylistDialogForm({
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
isPublic,
|
isPublic,
|
||||||
}: CreatePlaylistDialogFormProps) {
|
}: CreatePlaylistDialogFormProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title="Créer une playlist"
|
title={t('playlists.createDialog.title')}
|
||||||
variant="default"
|
variant="default"
|
||||||
aria-label="Dialogue de création de playlist"
|
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="title">Titre *</Label>
|
<Label htmlFor="title">{t('playlists.createDialog.titleLabel')} *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="title"
|
id="title"
|
||||||
{...register('title')}
|
{...register('title')}
|
||||||
placeholder="Ma nouvelle playlist"
|
placeholder={t('playlists.createDialog.titlePlaceholder')}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
aria-invalid={errors.title ? 'true' : 'false'}
|
aria-invalid={errors.title ? 'true' : 'false'}
|
||||||
aria-describedby={errors.title ? 'create-title-error' : undefined}
|
aria-describedby={errors.title ? 'create-title-error' : undefined}
|
||||||
|
|
@ -63,11 +65,11 @@ export function CreatePlaylistDialogForm({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="description">Description</Label>
|
<Label htmlFor="description">{t('playlists.createDialog.descriptionLabel')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="description"
|
id="description"
|
||||||
{...register('description')}
|
{...register('description')}
|
||||||
placeholder="Décrivez votre playlist..."
|
placeholder={t('playlists.createDialog.descriptionPlaceholder')}
|
||||||
rows={3}
|
rows={3}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
aria-invalid={errors.description ? 'true' : 'false'}
|
aria-invalid={errors.description ? 'true' : 'false'}
|
||||||
|
|
@ -95,7 +97,7 @@ export function CreatePlaylistDialogForm({
|
||||||
aria-checked={isPublic}
|
aria-checked={isPublic}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="is_public" className="cursor-pointer">
|
<Label htmlFor="is_public" className="cursor-pointer">
|
||||||
Playlist publique
|
{t('playlists.createDialog.publicPlaylist')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -105,19 +107,19 @@ export function CreatePlaylistDialogForm({
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
aria-label="Annuler la création de playlist"
|
aria-label={t('playlists.createDialog.cancel')}
|
||||||
>
|
>
|
||||||
Annuler
|
{t('playlists.createDialog.cancelButton')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
aria-label="Créer la playlist"
|
aria-label={t('playlists.createDialog.submit')}
|
||||||
>
|
>
|
||||||
{isSubmitting && (
|
{isSubmitting && (
|
||||||
<LoadingSpinner size="sm" inline className="mr-2" />
|
<LoadingSpinner size="sm" inline className="mr-2" />
|
||||||
)}
|
)}
|
||||||
Créer
|
{t('playlists.createDialog.submitButton')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog';
|
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { usePlaylistActions } from './usePlaylistActions';
|
import { usePlaylistActions } from './usePlaylistActions';
|
||||||
import { PlaylistActionsButtons } from './PlaylistActionsButtons';
|
import { PlaylistActionsButtons } from './PlaylistActionsButtons';
|
||||||
import { PlaylistActionsEditDialog } from './PlaylistActionsEditDialog';
|
import { PlaylistActionsEditDialog } from './PlaylistActionsEditDialog';
|
||||||
|
|
@ -11,6 +12,7 @@ export function PlaylistActions({
|
||||||
canShare = false,
|
canShare = false,
|
||||||
className,
|
className,
|
||||||
}: PlaylistActionsProps) {
|
}: PlaylistActionsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
permissions,
|
permissions,
|
||||||
showEditDialog,
|
showEditDialog,
|
||||||
|
|
@ -32,7 +34,7 @@ export function PlaylistActions({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className} role="group" aria-label="Actions de la playlist">
|
<div className={className} role="group" aria-label={t('playlists.actions.groupLabel')}>
|
||||||
<PlaylistActionsButtons
|
<PlaylistActionsButtons
|
||||||
canEdit={permissions.canEdit}
|
canEdit={permissions.canEdit}
|
||||||
canDelete={permissions.canDelete}
|
canDelete={permissions.canDelete}
|
||||||
|
|
@ -55,10 +57,10 @@ export function PlaylistActions({
|
||||||
open={showDeleteDialog}
|
open={showDeleteDialog}
|
||||||
onClose={() => setShowDeleteDialog(false)}
|
onClose={() => setShowDeleteDialog(false)}
|
||||||
onConfirm={handleDeleteConfirm}
|
onConfirm={handleDeleteConfirm}
|
||||||
title="Supprimer la playlist"
|
title={t('playlists.actions.deleteTitle')}
|
||||||
description={`Êtes-vous sûr de vouloir supprimer « ${playlist.title} » ? Cette action est irréversible. Tous les titres de la playlist seront retirés.`}
|
description={t('playlists.actions.deleteConfirmation', { title: playlist.title })}
|
||||||
confirmLabel="Supprimer"
|
confirmLabel={t('playlists.actions.deleteConfirm')}
|
||||||
cancelLabel="Annuler"
|
cancelLabel={t('playlists.actions.deleteCancel')}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
isLoading={deleteMutation.isPending}
|
isLoading={deleteMutation.isPending}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Edit, Trash2, Share2, CheckCircle2 } from 'lucide-react';
|
import { Edit, Trash2, Share2, CheckCircle2 } from 'lucide-react';
|
||||||
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
|
||||||
interface PlaylistActionsButtonsProps {
|
interface PlaylistActionsButtonsProps {
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
|
|
@ -23,6 +24,7 @@ export function PlaylistActionsButtons({
|
||||||
isBusy,
|
isBusy,
|
||||||
showSuccess,
|
showSuccess,
|
||||||
}: PlaylistActionsButtonsProps) {
|
}: PlaylistActionsButtonsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col sm:flex-row gap-2 sm:gap-2 mb-4 sm:mb-6">
|
<div className="flex flex-col sm:flex-row gap-2 sm:gap-2 mb-4 sm:mb-6">
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
|
|
@ -30,13 +32,13 @@ export function PlaylistActionsButtons({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onEditClick}
|
onClick={onEditClick}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
aria-label="Modifier la playlist"
|
aria-label={t('playlists.actions.editPlaylist')}
|
||||||
className="touch-manipulation min-h-11 sm:min-h-0 w-full sm:w-auto"
|
className="touch-manipulation min-h-11 sm:min-h-0 w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
{isBusy ? (
|
{isBusy ? (
|
||||||
<>
|
<>
|
||||||
<LoadingSpinner size="sm" inline className="sm:mr-2" />
|
<LoadingSpinner size="sm" inline className="sm:mr-2" />
|
||||||
<span className="hidden sm:inline">Enregistrement...</span>
|
<span className="hidden sm:inline">{t('playlists.actions.saving')}</span>
|
||||||
</>
|
</>
|
||||||
) : showSuccess ? (
|
) : showSuccess ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -44,12 +46,12 @@ export function PlaylistActionsButtons({
|
||||||
className="w-4 h-4 sm:mr-2 text-green-600 dark:text-green-400"
|
className="w-4 h-4 sm:mr-2 text-green-600 dark:text-green-400"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<span className="hidden sm:inline">Enregistré</span>
|
<span className="hidden sm:inline">{t('playlists.actions.saved')}</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Edit className="w-4 h-4 sm:mr-2" aria-hidden />
|
<Edit className="w-4 h-4 sm:mr-2" aria-hidden />
|
||||||
Modifier
|
{t('playlists.actions.edit')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -59,11 +61,11 @@ export function PlaylistActionsButtons({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onShareClick}
|
onClick={onShareClick}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
aria-label="Partager la playlist"
|
aria-label={t('playlists.actions.sharePlaylist')}
|
||||||
className="touch-manipulation min-h-11 sm:min-h-0 w-full sm:w-auto"
|
className="touch-manipulation min-h-11 sm:min-h-0 w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
<Share2 className="w-4 h-4 sm:mr-2" aria-hidden />
|
<Share2 className="w-4 h-4 sm:mr-2" aria-hidden />
|
||||||
Partager
|
{t('playlists.actions.share')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
|
|
@ -71,11 +73,11 @@ export function PlaylistActionsButtons({
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={onDeleteClick}
|
onClick={onDeleteClick}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
aria-label="Supprimer la playlist"
|
aria-label={t('playlists.actions.deletePlaylist')}
|
||||||
className="touch-manipulation min-h-11 sm:min-h-0 w-full sm:w-auto"
|
className="touch-manipulation min-h-11 sm:min-h-0 w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 sm:mr-2" aria-hidden />
|
<Trash2 className="w-4 h-4 sm:mr-2" aria-hidden />
|
||||||
Supprimer
|
{t('playlists.actions.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Dialog } from '@/components/ui/dialog';
|
import { Dialog } from '@/components/ui/dialog';
|
||||||
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import type { UpdatePlaylistRequest } from '../../types';
|
import type { UpdatePlaylistRequest } from '../../types';
|
||||||
|
|
||||||
interface PlaylistActionsEditDialogProps {
|
interface PlaylistActionsEditDialogProps {
|
||||||
|
|
@ -20,23 +21,24 @@ export function PlaylistActionsEditDialog({
|
||||||
onConfirm,
|
onConfirm,
|
||||||
isPending,
|
isPending,
|
||||||
}: PlaylistActionsEditDialogProps) {
|
}: PlaylistActionsEditDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title="Modifier la playlist"
|
title={t('playlists.editDialog.title')}
|
||||||
variant="default"
|
variant="default"
|
||||||
onConfirm={onConfirm}
|
onConfirm={onConfirm}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
confirmLabel={isPending ? 'Enregistrement...' : 'Enregistrer'}
|
confirmLabel={isPending ? t('playlists.editDialog.saving') : t('playlists.editDialog.save')}
|
||||||
cancelLabel="Annuler"
|
cancelLabel={t('playlists.editDialog.cancel')}
|
||||||
showCancel
|
showCancel
|
||||||
size="md"
|
size="md"
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="edit-title" className="block text-sm font-medium mb-2">
|
<label htmlFor="edit-title" className="block text-sm font-medium mb-2">
|
||||||
Titre
|
{t('playlists.editDialog.titleLabel')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="edit-title"
|
id="edit-title"
|
||||||
|
|
@ -44,7 +46,7 @@ export function PlaylistActionsEditDialog({
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onFormChange({ ...editForm, title: e.target.value })
|
onFormChange({ ...editForm, title: e.target.value })
|
||||||
}
|
}
|
||||||
placeholder="Titre de la playlist"
|
placeholder={t('playlists.editDialog.titlePlaceholder')}
|
||||||
aria-required
|
aria-required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -53,7 +55,7 @@ export function PlaylistActionsEditDialog({
|
||||||
htmlFor="edit-description"
|
htmlFor="edit-description"
|
||||||
className="block text-sm font-medium mb-2"
|
className="block text-sm font-medium mb-2"
|
||||||
>
|
>
|
||||||
Description
|
{t('playlists.editDialog.descriptionLabel')}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="edit-description"
|
id="edit-description"
|
||||||
|
|
@ -63,7 +65,7 @@ export function PlaylistActionsEditDialog({
|
||||||
}
|
}
|
||||||
className="w-full px-4 py-2 border border-input rounded-md bg-background text-foreground min-h-24"
|
className="w-full px-4 py-2 border border-input rounded-md bg-background text-foreground min-h-24"
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="Description de la playlist"
|
placeholder={t('playlists.editDialog.descriptionPlaceholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -71,7 +73,7 @@ export function PlaylistActionsEditDialog({
|
||||||
htmlFor="edit-cover-url"
|
htmlFor="edit-cover-url"
|
||||||
className="block text-sm font-medium mb-2"
|
className="block text-sm font-medium mb-2"
|
||||||
>
|
>
|
||||||
URL de la couverture
|
{t('playlists.editDialog.coverUrlLabel')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="edit-cover-url"
|
id="edit-cover-url"
|
||||||
|
|
@ -95,7 +97,7 @@ export function PlaylistActionsEditDialog({
|
||||||
aria-checked={editForm.is_public ?? false}
|
aria-checked={editForm.is_public ?? false}
|
||||||
/>
|
/>
|
||||||
<label htmlFor="edit-is_public" className="text-sm font-medium">
|
<label htmlFor="edit-is_public" className="text-sm font-medium">
|
||||||
Playlist publique
|
{t('playlists.editDialog.isPublic')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{isPending && (
|
{isPending && (
|
||||||
|
|
@ -105,7 +107,7 @@ export function PlaylistActionsEditDialog({
|
||||||
aria-live="assertive"
|
aria-live="assertive"
|
||||||
>
|
>
|
||||||
<LoadingSpinner size="sm" inline />
|
<LoadingSpinner size="sm" inline />
|
||||||
<span>Enregistrement en cours...</span>
|
<span>{t('playlists.editDialog.savingInProgress')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useUpdatePlaylist, useDeletePlaylist } from '../../hooks/usePlaylist';
|
import { useUpdatePlaylist, useDeletePlaylist } from '../../hooks/usePlaylist';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { usePlaylistPermissions } from '../../hooks/usePlaylistPermissions';
|
import { usePlaylistPermissions } from '../../hooks/usePlaylistPermissions';
|
||||||
import type { Playlist, UpdatePlaylistRequest } from '../../types';
|
import type { Playlist, UpdatePlaylistRequest } from '../../types';
|
||||||
|
|
||||||
|
|
@ -17,6 +18,7 @@ export function usePlaylistActions({
|
||||||
onUpdated,
|
onUpdated,
|
||||||
canShare: _canShare = false,
|
canShare: _canShare = false,
|
||||||
}: UsePlaylistActionsOptions) {
|
}: UsePlaylistActionsOptions) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { success: showSuccessToast, error: showError } = useToast();
|
const { success: showSuccessToast, error: showError } = useToast();
|
||||||
const permissions = usePlaylistPermissions(playlist);
|
const permissions = usePlaylistPermissions(playlist);
|
||||||
|
|
@ -62,14 +64,14 @@ export function usePlaylistActions({
|
||||||
id: playlist.id,
|
id: playlist.id,
|
||||||
data: editForm,
|
data: editForm,
|
||||||
});
|
});
|
||||||
showSuccessToast('Playlist mise à jour avec succès');
|
showSuccessToast(t('playlists.actions.updateSuccess'));
|
||||||
setShowEditDialog(false);
|
setShowEditDialog(false);
|
||||||
onUpdated?.();
|
onUpdated?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(
|
showError(
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: 'Erreur lors de la mise à jour',
|
: t('playlists.actions.updateError'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -77,14 +79,14 @@ export function usePlaylistActions({
|
||||||
const handleDeleteConfirm = async () => {
|
const handleDeleteConfirm = async () => {
|
||||||
try {
|
try {
|
||||||
await deleteMutation.mutateAsync(playlist.id);
|
await deleteMutation.mutateAsync(playlist.id);
|
||||||
showSuccessToast('Playlist supprimée avec succès');
|
showSuccessToast(t('playlists.actions.deleteSuccess'));
|
||||||
setShowDeleteDialog(false);
|
setShowDeleteDialog(false);
|
||||||
navigate('/playlists');
|
navigate('/playlists');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(
|
showError(
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: 'Erreur lors de la suppression',
|
: t('playlists.actions.deleteError'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Library } from 'lucide-react';
|
import { Library } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import type { PlaylistListEmptyVariant } from './types';
|
import type { PlaylistListEmptyVariant } from './types';
|
||||||
|
|
||||||
interface PlaylistListEmptyProps {
|
interface PlaylistListEmptyProps {
|
||||||
|
|
@ -8,6 +9,8 @@ interface PlaylistListEmptyProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlaylistListEmpty({ variant, className }: PlaylistListEmptyProps) {
|
export function PlaylistListEmpty({ variant, className }: PlaylistListEmptyProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (variant === 'no_playlists') {
|
if (variant === 'no_playlists') {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -16,9 +19,9 @@ export function PlaylistListEmpty({ variant, className }: PlaylistListEmptyProps
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
<Library className="h-12 w-12 mx-auto mb-4 text-muted-foreground opacity-50" />
|
<Library className="h-12 w-12 mx-auto mb-4 text-muted-foreground opacity-50" />
|
||||||
<h3 className="text-lg font-semibold mb-2">No playlists yet</h3>
|
<h3 className="text-lg font-semibold mb-2">{t('playlists.emptyTitle')}</h3>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Start by creating your first playlist to organize your tracks.
|
{t('playlists.emptyDescription')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -31,10 +34,9 @@ export function PlaylistListEmpty({ variant, className }: PlaylistListEmptyProps
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
<Library className="h-12 w-12 mx-auto mb-4 text-muted-foreground opacity-50" />
|
<Library className="h-12 w-12 mx-auto mb-4 text-muted-foreground opacity-50" />
|
||||||
<h3 className="text-lg font-semibold mb-2">No playlists found</h3>
|
<h3 className="text-lg font-semibold mb-2">{t('search.noResults')}</h3>
|
||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-muted-foreground mb-4">
|
||||||
No playlists match your search criteria. Try adjusting your filters or
|
{t('search.noResultsHint')}
|
||||||
search terms.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { usePlaylistTrackList } from './usePlaylistTrackList';
|
||||||
import { PlaylistTrackListEmpty } from './PlaylistTrackListEmpty';
|
import { PlaylistTrackListEmpty } from './PlaylistTrackListEmpty';
|
||||||
import { PlaylistTrackListSortableItem } from './PlaylistTrackListSortableItem';
|
import { PlaylistTrackListSortableItem } from './PlaylistTrackListSortableItem';
|
||||||
import { PlaylistTrackListSkeleton } from './PlaylistTrackListSkeleton';
|
import { PlaylistTrackListSkeleton } from './PlaylistTrackListSkeleton';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import type { PlaylistTrackListProps } from './types';
|
import type { PlaylistTrackListProps } from './types';
|
||||||
|
|
||||||
export function PlaylistTrackList({
|
export function PlaylistTrackList({
|
||||||
|
|
@ -25,13 +26,14 @@ export function PlaylistTrackList({
|
||||||
isPlaying,
|
isPlaying,
|
||||||
currentPlayingId,
|
currentPlayingId,
|
||||||
className,
|
className,
|
||||||
emptyMessage = 'Aucun track dans cette playlist',
|
emptyMessage,
|
||||||
emptyDescription = 'Ajoutez des tracks à cette playlist pour commencer.',
|
emptyDescription,
|
||||||
enableDragAndDrop = true,
|
enableDragAndDrop = true,
|
||||||
canRemoveTracks = true,
|
canRemoveTracks = true,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isFavorisPlaylist = false,
|
isFavorisPlaylist = false,
|
||||||
}: PlaylistTrackListProps) {
|
}: PlaylistTrackListProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
sortedPlaylistTracks,
|
sortedPlaylistTracks,
|
||||||
trackMap,
|
trackMap,
|
||||||
|
|
@ -67,7 +69,7 @@ export function PlaylistTrackList({
|
||||||
const listClassName = cn('space-y-1', className);
|
const listClassName = cn('space-y-1', className);
|
||||||
const listProps = {
|
const listProps = {
|
||||||
role: 'list' as const,
|
role: 'list' as const,
|
||||||
'aria-label': 'Liste des tracks de la playlist',
|
'aria-label': t('playlists.trackListLabel'),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!enableDragAndDrop) {
|
if (!enableDragAndDrop) {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
*/
|
*/
|
||||||
import { Music } from 'lucide-react';
|
import { Music } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
|
||||||
interface PlaylistTrackListEmptyProps {
|
interface PlaylistTrackListEmptyProps {
|
||||||
message?: string;
|
message?: string;
|
||||||
|
|
@ -11,10 +12,13 @@ interface PlaylistTrackListEmptyProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlaylistTrackListEmpty({
|
export function PlaylistTrackListEmpty({
|
||||||
message = 'Aucun track dans cette playlist',
|
message,
|
||||||
description = 'Ajoutez des tracks à cette playlist pour commencer.',
|
description,
|
||||||
className,
|
className,
|
||||||
}: PlaylistTrackListEmptyProps) {
|
}: PlaylistTrackListEmptyProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const displayMessage = message ?? t('playlists.detail.emptyTracks');
|
||||||
|
const displayDescription = description ?? t('playlists.detail.emptyTracksDescription');
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -24,11 +28,11 @@ export function PlaylistTrackListEmpty({
|
||||||
>
|
>
|
||||||
<Music className="h-12 w-12 text-muted-foreground mb-4 opacity-50" />
|
<Music className="h-12 w-12 text-muted-foreground mb-4 opacity-50" />
|
||||||
<p className="text-lg font-medium text-foreground mb-2">
|
<p className="text-lg font-medium text-foreground mb-2">
|
||||||
{message}
|
{displayMessage}
|
||||||
</p>
|
</p>
|
||||||
{description && (
|
{displayDescription && (
|
||||||
<p className="text-sm text-muted-foreground max-w-md">
|
<p className="text-sm text-muted-foreground max-w-md">
|
||||||
{description}
|
{displayDescription}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
*/
|
*/
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
|
||||||
interface PlaylistTrackListSkeletonProps {
|
interface PlaylistTrackListSkeletonProps {
|
||||||
count?: number;
|
count?: number;
|
||||||
|
|
@ -13,11 +14,12 @@ export function PlaylistTrackListSkeleton({
|
||||||
count = 5,
|
count = 5,
|
||||||
className,
|
className,
|
||||||
}: PlaylistTrackListSkeletonProps) {
|
}: PlaylistTrackListSkeletonProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn('space-y-1', className)}
|
className={cn('space-y-1', className)}
|
||||||
role="status"
|
role="status"
|
||||||
aria-label="Chargement des pistes"
|
aria-label={t('playlists.detail.loadingTracks')}
|
||||||
>
|
>
|
||||||
{Array.from({ length: count }).map((_, index) => (
|
{Array.from({ length: count }).map((_, index) => (
|
||||||
<div
|
<div
|
||||||
|
|
@ -44,7 +46,7 @@ export function PlaylistTrackListSkeleton({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<span className="sr-only">Chargement des pistes en cours...</span>
|
<span className="sr-only">{t('playlists.detail.loadingTracksProgress')}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
} from '@dnd-kit/sortable';
|
} from '@dnd-kit/sortable';
|
||||||
import { useReorderPlaylistTracks } from '../../hooks/usePlaylist';
|
import { useReorderPlaylistTracks } from '../../hooks/usePlaylist';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { sortTracksByPosition } from './utils';
|
import { sortTracksByPosition } from './utils';
|
||||||
import type { PlaylistTrack, Track } from '../../types';
|
import type { PlaylistTrack, Track } from '../../types';
|
||||||
|
|
||||||
|
|
@ -37,6 +38,7 @@ export function usePlaylistTrackList(
|
||||||
[tracks],
|
[tracks],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
const { toast, error: showError } = useToast();
|
const { toast, error: showError } = useToast();
|
||||||
const reorderMutation = useReorderPlaylistTracks();
|
const reorderMutation = useReorderPlaylistTracks();
|
||||||
|
|
||||||
|
|
@ -67,11 +69,11 @@ export function usePlaylistTrackList(
|
||||||
playlistId: String(playlistId),
|
playlistId: String(playlistId),
|
||||||
trackIds,
|
trackIds,
|
||||||
});
|
});
|
||||||
toast({ message: 'Playlist réorganisée', type: 'success' });
|
toast({ message: t('playlists.detail.playlistReordered'), type: 'success' });
|
||||||
onTracksReordered?.();
|
onTracksReordered?.();
|
||||||
} catch {
|
} catch {
|
||||||
setSortedPlaylistTracks(sortTracksByPosition(playlistTracks));
|
setSortedPlaylistTracks(sortTracksByPosition(playlistTracks));
|
||||||
showError('Impossible de réorganiser la playlist. Veuillez réessayer.');
|
showError(t('playlists.detail.reorderError'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
|
@ -82,6 +84,7 @@ export function usePlaylistTrackList(
|
||||||
toast,
|
toast,
|
||||||
showError,
|
showError,
|
||||||
onTracksReordered,
|
onTracksReordered,
|
||||||
|
t,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import type {
|
||||||
CreatePlaylistRequest,
|
CreatePlaylistRequest,
|
||||||
UpdatePlaylistRequest,
|
UpdatePlaylistRequest,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { TokenStorage } from '@/services/tokenStorage';
|
import { useAuthStore } from '@/features/auth/store/authStore';
|
||||||
import { useUser } from '@/features/auth/hooks/useUser';
|
import { useUser } from '@/features/auth/hooks/useUser';
|
||||||
|
|
||||||
// Create Playlist
|
// Create Playlist
|
||||||
|
|
@ -183,13 +183,14 @@ export function usePlaylists(
|
||||||
sortBy?: 'created_at' | 'title' | 'track_count',
|
sortBy?: 'created_at' | 'title' | 'track_count',
|
||||||
sortOrder?: 'asc' | 'desc',
|
sortOrder?: 'asc' | 'desc',
|
||||||
) {
|
) {
|
||||||
// Check if token exists in storage (more reliable than waiting for store hydration)
|
// With httpOnly cookies, TokenStorage.getAccessToken() is always null.
|
||||||
const hasToken = !!TokenStorage.getAccessToken();
|
// Use auth store's isAuthenticated flag instead.
|
||||||
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
const page = Math.floor(offset / limit) + 1;
|
const page = Math.floor(offset / limit) + 1;
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['playlists', limit, offset, sortBy, sortOrder],
|
queryKey: ['playlists', limit, offset, sortBy, sortOrder],
|
||||||
queryFn: () => listPlaylists(page, limit, undefined, sortBy, sortOrder),
|
queryFn: () => listPlaylists(page, limit, undefined, sortBy, sortOrder),
|
||||||
enabled: hasToken, // Only fetch if token exists
|
enabled: isAuthenticated,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
* FE-PAGE-009: Complete Playlist List page implementation
|
* FE-PAGE-009: Complete Playlist List page implementation
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { PlaylistList } from '../components/PlaylistList';
|
import { PlaylistList } from '../components/PlaylistList';
|
||||||
import { CreatePlaylistDialog } from '../components/CreatePlaylistDialog';
|
import { CreatePlaylistDialog } from '../components/CreatePlaylistDialog';
|
||||||
import { ImportPlaylistButton } from '../components/ImportPlaylistButton';
|
import { ImportPlaylistButton } from '../components/ImportPlaylistButton';
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
X,
|
X,
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import '../styles/playlists.mobile.css';
|
import '../styles/playlists.mobile.css';
|
||||||
|
|
||||||
// FE-PAGE-009: Complete Playlist List page implementation
|
// FE-PAGE-009: Complete Playlist List page implementation
|
||||||
|
|
@ -30,11 +31,16 @@ type SortField = 'created_at' | 'title' | 'track_count';
|
||||||
type SortOrder = 'asc' | 'desc';
|
type SortOrder = 'asc' | 'desc';
|
||||||
|
|
||||||
export function PlaylistListPage() {
|
export function PlaylistListPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [enableSelection, setEnableSelection] = useState(false);
|
const [enableSelection, setEnableSelection] = useState(false);
|
||||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = t('playlists.pageTitle');
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
// FE-PAGE-009: Filtering state
|
// FE-PAGE-009: Filtering state
|
||||||
const [filterIsPublic, setFilterIsPublic] = useState<boolean | undefined>(
|
const [filterIsPublic, setFilterIsPublic] = useState<boolean | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
|
|
@ -63,10 +69,10 @@ export function PlaylistListPage() {
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 sm:gap-0">
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 sm:gap-0">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-heading-1 font-heading text-foreground">
|
<h1 className="text-heading-1 font-heading text-foreground">
|
||||||
Playlists
|
{t('playlists.title')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm sm:text-base text-muted-foreground">
|
<p className="text-sm sm:text-base text-muted-foreground">
|
||||||
Découvrez et gérez vos playlists
|
{t('playlists.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|
@ -75,12 +81,12 @@ export function PlaylistListPage() {
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsCreateDialogOpen(true)}
|
onClick={() => setIsCreateDialogOpen(true)}
|
||||||
className="touch-manipulation min-h-11 sm:min-h-0"
|
className="touch-manipulation min-h-11 sm:min-h-0"
|
||||||
aria-label="Créer une nouvelle playlist"
|
aria-label={t('playlists.createNewPlaylist')}
|
||||||
data-testid="create-playlist-btn"
|
data-testid="create-playlist-btn"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4 sm:mr-2" aria-hidden="true" />
|
<Plus className="h-4 w-4 sm:mr-2" aria-hidden="true" />
|
||||||
<span className="hidden sm:inline">Créer</span>
|
<span className="hidden sm:inline">{t('playlists.createButton')}</span>
|
||||||
<span className="sm:hidden">Nouvelle</span>
|
<span className="sm:hidden">{t('playlists.createButtonMobile')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<ImportPlaylistButton className="touch-manipulation min-h-11 sm:min-h-0" />
|
<ImportPlaylistButton className="touch-manipulation min-h-11 sm:min-h-0" />
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -90,13 +96,13 @@ export function PlaylistListPage() {
|
||||||
className="touch-manipulation min-h-11 sm:min-h-0"
|
className="touch-manipulation min-h-11 sm:min-h-0"
|
||||||
aria-label={
|
aria-label={
|
||||||
enableSelection
|
enableSelection
|
||||||
? 'Désactiver la sélection'
|
? t('playlists.disableSelection')
|
||||||
: 'Activer la sélection'
|
: t('playlists.enableSelection')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<CheckSquare className="h-4 w-4 sm:mr-2" aria-hidden="true" />
|
<CheckSquare className="h-4 w-4 sm:mr-2" aria-hidden="true" />
|
||||||
<span className="hidden sm:inline">
|
<span className="hidden sm:inline">
|
||||||
{enableSelection ? 'Annuler' : 'Sélectionner'}
|
{enableSelection ? t('playlists.deselectButton') : t('playlists.selectButton')}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -113,7 +119,8 @@ export function PlaylistListPage() {
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder="Rechercher des playlists..."
|
placeholder={t('playlists.searchPlaceholder')}
|
||||||
|
aria-label={t('playlists.searchPlaceholder')}
|
||||||
className="pl-10"
|
className="pl-10"
|
||||||
data-testid="playlist-search"
|
data-testid="playlist-search"
|
||||||
/>
|
/>
|
||||||
|
|
@ -123,17 +130,17 @@ export function PlaylistListPage() {
|
||||||
onClick={() => setShowFilters(!showFilters)}
|
onClick={() => setShowFilters(!showFilters)}
|
||||||
>
|
>
|
||||||
<Filter className="mr-2 h-4 w-4" />
|
<Filter className="mr-2 h-4 w-4" />
|
||||||
Filters
|
{t('playlists.filtersButton')}
|
||||||
{hasActiveFilters && (
|
{hasActiveFilters && (
|
||||||
<span className="ml-2 bg-primary text-foreground rounded-full px-2 py-0.5 text-xs">
|
<span className="ml-2 bg-primary text-foreground rounded-full px-2 py-0.5 text-xs">
|
||||||
Active
|
{t('playlists.filtersActive')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
{hasActiveFilters && (
|
{hasActiveFilters && (
|
||||||
<Button variant="ghost" onClick={handleClearFilters}>
|
<Button variant="ghost" onClick={handleClearFilters}>
|
||||||
<X className="mr-2 h-4 w-4" />
|
<X className="mr-2 h-4 w-4" />
|
||||||
Clear
|
{t('playlists.clearFilters')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -141,7 +148,7 @@ export function PlaylistListPage() {
|
||||||
{showFilters && (
|
{showFilters && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-4 border-t">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-4 border-t">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Visibility</label>
|
<label className="text-sm font-medium">{t('playlists.filterVisibility')}</label>
|
||||||
<Select
|
<Select
|
||||||
value={
|
value={
|
||||||
filterIsPublic === undefined
|
filterIsPublic === undefined
|
||||||
|
|
@ -156,14 +163,14 @@ export function PlaylistListPage() {
|
||||||
else setFilterIsPublic(false);
|
else setFilterIsPublic(false);
|
||||||
}}
|
}}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'all', label: 'All' },
|
{ value: 'all', label: t('playlists.all') },
|
||||||
{ value: 'public', label: 'Public' },
|
{ value: 'public', label: t('playlists.public') },
|
||||||
{ value: 'private', label: 'Private' },
|
{ value: 'private', label: t('playlists.private') },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Owner</label>
|
<label className="text-sm font-medium">{t('playlists.filterOwner')}</label>
|
||||||
<Select
|
<Select
|
||||||
value={filterOwner}
|
value={filterOwner}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
|
|
@ -175,14 +182,14 @@ export function PlaylistListPage() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'all', label: 'All' },
|
{ value: 'all', label: t('playlists.all') },
|
||||||
{ value: 'mine', label: 'My Playlists' },
|
{ value: 'mine', label: t('playlists.myPlaylists') },
|
||||||
{ value: 'others', label: 'Others' },
|
{ value: 'others', label: t('playlists.others') },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Sort By</label>
|
<label className="text-sm font-medium">{t('playlists.filterSortBy')}</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Select
|
<Select
|
||||||
value={sortBy}
|
value={sortBy}
|
||||||
|
|
@ -194,9 +201,9 @@ export function PlaylistListPage() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'created_at', label: 'Date' },
|
{ value: 'created_at', label: t('playlists.sortByDate') },
|
||||||
{ value: 'title', label: 'Title' },
|
{ value: 'title', label: t('playlists.sortByTitle') },
|
||||||
{ value: 'track_count', label: 'Tracks' },
|
{ value: 'track_count', label: t('playlists.sortByTracks') },
|
||||||
]}
|
]}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
|
|
@ -208,6 +215,7 @@ export function PlaylistListPage() {
|
||||||
prev === 'asc' ? 'desc' : 'asc',
|
prev === 'asc' ? 'desc' : 'asc',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
aria-label={t('playlists.sortToggle')}
|
||||||
>
|
>
|
||||||
<ArrowUpDown className="h-4 w-4" />
|
<ArrowUpDown className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
/**
|
/**
|
||||||
* SharedPlaylistPage — public view of a playlist via share token (v0.10.4 F143)
|
* SharedPlaylistPage — public view of a playlist via share token (v0.10.4 F143)
|
||||||
* No auth required; read-only display of playlist and tracks.
|
* No auth required; read-only display of playlist and tracks.
|
||||||
|
* Renders standalone (no DashboardLayout) to avoid auth-required API calls.
|
||||||
*/
|
*/
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
@ -8,6 +9,11 @@ import { Play, Shuffle, Copy } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
import { playlistsApi } from '@/services/api/playlists';
|
import { playlistsApi } from '@/services/api/playlists';
|
||||||
|
import { usePlayerStore } from '@/features/player/store/playerStore';
|
||||||
|
import { GlobalPlayer } from '@/features/player/components/GlobalPlayer';
|
||||||
|
import type { Track as PlayerTrack } from '@/features/player/types';
|
||||||
|
import type { Track as ApiTrack } from '@/features/tracks/types/track';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { PlaylistDetailPageHero } from './playlist-detail-page/PlaylistDetailPageHero';
|
import { PlaylistDetailPageHero } from './playlist-detail-page/PlaylistDetailPageHero';
|
||||||
import { PlaylistDetailPageCoverAndInfo } from './playlist-detail-page/PlaylistDetailPageCoverAndInfo';
|
import { PlaylistDetailPageCoverAndInfo } from './playlist-detail-page/PlaylistDetailPageCoverAndInfo';
|
||||||
import { PlaylistDetailPageSkeleton } from './playlist-detail-page/PlaylistDetailPageSkeleton';
|
import { PlaylistDetailPageSkeleton } from './playlist-detail-page/PlaylistDetailPageSkeleton';
|
||||||
|
|
@ -15,8 +21,23 @@ import { PlaylistDetailPageNotFound } from './playlist-detail-page/PlaylistDetai
|
||||||
import { PlaylistTrackList } from '../components/PlaylistTrackList';
|
import { PlaylistTrackList } from '../components/PlaylistTrackList';
|
||||||
import toast from '@/utils/toast';
|
import toast from '@/utils/toast';
|
||||||
|
|
||||||
|
function mapToPlayerTrack(t: ApiTrack): PlayerTrack {
|
||||||
|
return {
|
||||||
|
id: t.id,
|
||||||
|
title: t.title,
|
||||||
|
artist: t.artist,
|
||||||
|
album: t.album,
|
||||||
|
duration: t.duration,
|
||||||
|
url: (t as { stream_manifest_url?: string }).stream_manifest_url || `/api/v1/tracks/${t.id}/download`,
|
||||||
|
cover: (t as { cover_art_path?: string }).cover_art_path,
|
||||||
|
genre: t.genre,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function SharedPlaylistPage() {
|
export function SharedPlaylistPage() {
|
||||||
const { token } = useParams<{ token: string }>();
|
const { token } = useParams<{ token: string }>();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { play, addToQueue, clearQueue, toggleShuffle } = usePlayerStore();
|
||||||
|
|
||||||
const { data: playlist, isLoading, error } = useQuery({
|
const { data: playlist, isLoading, error } = useQuery({
|
||||||
queryKey: ['playlistShared', token],
|
queryKey: ['playlistShared', token],
|
||||||
|
|
@ -25,23 +46,48 @@ export function SharedPlaylistPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <PlaylistDetailPageSkeleton />;
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<PlaylistDetailPageSkeleton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !playlist) {
|
if (error || !playlist) {
|
||||||
return <PlaylistDetailPageNotFound />;
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<PlaylistDetailPageNotFound />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const playlistTracks = playlist.tracks ?? [];
|
const playlistTracks = playlist.tracks ?? [];
|
||||||
const tracks = playlistTracks.map((pt) => pt.track).filter((t): t is NonNullable<typeof t> => Boolean(t));
|
const tracks = playlistTracks.map((pt) => pt.track).filter((t): t is NonNullable<typeof t> => Boolean(t));
|
||||||
|
|
||||||
|
const handlePlayAll = () => {
|
||||||
|
if (tracks.length === 0) return;
|
||||||
|
const playerTracks = tracks.map(mapToPlayerTrack);
|
||||||
|
clearQueue();
|
||||||
|
addToQueue(playerTracks);
|
||||||
|
play(playerTracks[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShuffle = () => {
|
||||||
|
if (tracks.length === 0) return;
|
||||||
|
const playerTracks = tracks.map(mapToPlayerTrack);
|
||||||
|
clearQueue();
|
||||||
|
addToQueue(playerTracks);
|
||||||
|
play(playerTracks[0]);
|
||||||
|
toggleShuffle();
|
||||||
|
};
|
||||||
|
|
||||||
const handleCopyLink = () => {
|
const handleCopyLink = () => {
|
||||||
navigator.clipboard.writeText(window.location.href);
|
navigator.clipboard.writeText(window.location.href);
|
||||||
toast.success('Link copied to clipboard');
|
toast.success(t('playlists.shared.linkCopied'));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-layout-page pb-24">
|
<div className="min-h-screen bg-background pb-32 relative">
|
||||||
<PlaylistDetailPageHero playlist={playlist} />
|
<PlaylistDetailPageHero playlist={playlist} />
|
||||||
<div className="container mx-auto px-4 md:px-8 relative -mt-40 z-10">
|
<div className="container mx-auto px-4 md:px-8 relative -mt-40 z-10">
|
||||||
<PlaylistDetailPageCoverAndInfo playlist={playlist} />
|
<PlaylistDetailPageCoverAndInfo playlist={playlist} />
|
||||||
|
|
@ -49,15 +95,19 @@ export function SharedPlaylistPage() {
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
className="rounded-full h-14 px-8 text-lg font-bold shadow-sm transition-all duration-[var(--sumi-duration-normal)] bg-primary text-primary-foreground"
|
className="rounded-full h-14 px-8 text-lg font-bold shadow-sm transition-all duration-[var(--sumi-duration-normal)] bg-primary text-primary-foreground"
|
||||||
|
onClick={handlePlayAll}
|
||||||
|
aria-label={t('playlists.shared.playAll')}
|
||||||
>
|
>
|
||||||
<Play className="w-5 h-5 mr-2 fill-current" /> Play All
|
<Play className="w-5 h-5 mr-2 fill-current" /> {t('playlists.shared.playAll')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="rounded-full h-14 px-6 border-white/10 hover:bg-white/5 backdrop-blur-sm transition-colors duration-[var(--duration-fast)]"
|
className="rounded-full h-14 px-6 border-white/10 hover:bg-white/5 backdrop-blur-sm transition-colors duration-[var(--duration-fast)]"
|
||||||
|
onClick={handleShuffle}
|
||||||
|
aria-label={t('playlists.shared.shuffle')}
|
||||||
>
|
>
|
||||||
<Shuffle className="w-5 h-5 mr-2" /> Shuffle
|
<Shuffle className="w-5 h-5 mr-2" /> {t('playlists.shared.shuffle')}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -65,14 +115,15 @@ export function SharedPlaylistPage() {
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="rounded-full border-white/10 hover:bg-white/5"
|
className="rounded-full border-white/10 hover:bg-white/5"
|
||||||
onClick={handleCopyLink}
|
onClick={handleCopyLink}
|
||||||
|
aria-label={t('playlists.shared.copyLink')}
|
||||||
>
|
>
|
||||||
<Copy className="w-5 h-5 mr-2" /> Copy link
|
<Copy className="w-5 h-5 mr-2" /> {t('playlists.shared.copyLink')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Card variant="glass" className="overflow-hidden border-white/5">
|
<Card variant="glass" className="overflow-hidden border-white/5">
|
||||||
<div className="p-4 border-b border-white/5 bg-black/20">
|
<div className="p-4 border-b border-white/5 bg-black/20">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Shared playlist · {tracks.length} track{tracks.length !== 1 ? 's' : ''}
|
{t('playlists.shared.sharedPlaylist')} · {t('playlists.shared.trackCount', { count: tracks.length })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-0">
|
<div className="p-0">
|
||||||
|
|
@ -84,11 +135,16 @@ export function SharedPlaylistPage() {
|
||||||
onTracksReordered={() => {}}
|
onTracksReordered={() => {}}
|
||||||
enableDragAndDrop={false}
|
enableDragAndDrop={false}
|
||||||
canRemoveTracks={false}
|
canRemoveTracks={false}
|
||||||
|
emptyMessage={t('playlists.shared.noTracks')}
|
||||||
|
emptyDescription={t('playlists.shared.noTracksDescription')}
|
||||||
className="divide-y divide-white/5"
|
className="divide-y divide-white/5"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 z-50">
|
||||||
|
<GlobalPlayer />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
import { PlaylistDetailPageHero } from './PlaylistDetailPageHero';
|
import { PlaylistDetailPageHero } from './PlaylistDetailPageHero';
|
||||||
import { PlaylistDetailPageCoverAndInfo } from './PlaylistDetailPageCoverAndInfo';
|
import { PlaylistDetailPageCoverAndInfo } from './PlaylistDetailPageCoverAndInfo';
|
||||||
import { PlaylistDetailPageActionsBar } from './PlaylistDetailPageActionsBar';
|
import { PlaylistDetailPageActionsBar } from './PlaylistDetailPageActionsBar';
|
||||||
|
|
@ -32,6 +33,12 @@ export function PlaylistDetailPage(props?: PlaylistDetailPageProps) {
|
||||||
onCollaboratorAdded,
|
onCollaboratorAdded,
|
||||||
} = usePlaylistDetailPage(playlistIdOverride);
|
} = usePlaylistDetailPage(playlistIdOverride);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (playlist?.title) {
|
||||||
|
document.title = `${playlist.title} — Veza`;
|
||||||
|
}
|
||||||
|
}, [playlist?.title]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <PlaylistDetailPageSkeleton />;
|
return <PlaylistDetailPageSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Play, Shuffle } from 'lucide-react';
|
import { Play, Shuffle } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { PlaylistActions } from '../../components/playlist-actions';
|
import { PlaylistActions } from '../../components/playlist-actions';
|
||||||
import { PlaylistFollowButton } from '../../components/PlaylistFollowButton';
|
import { PlaylistFollowButton } from '../../components/PlaylistFollowButton';
|
||||||
import { DuplicatePlaylistButton } from '../../components/DuplicatePlaylistButton';
|
import { DuplicatePlaylistButton } from '../../components/DuplicatePlaylistButton';
|
||||||
|
|
@ -20,6 +21,7 @@ export function PlaylistDetailPageActionsBar({
|
||||||
onShareClick,
|
onShareClick,
|
||||||
onRefetch,
|
onRefetch,
|
||||||
}: PlaylistDetailPageActionsBarProps) {
|
}: PlaylistDetailPageActionsBarProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const followerCount = (playlist as { follower_count?: number }).follower_count ?? 0;
|
const followerCount = (playlist as { follower_count?: number }).follower_count ?? 0;
|
||||||
const isFollowing = (playlist as { is_following?: boolean }).is_following ?? false;
|
const isFollowing = (playlist as { is_following?: boolean }).is_following ?? false;
|
||||||
|
|
@ -30,14 +32,14 @@ export function PlaylistDetailPageActionsBar({
|
||||||
size="lg"
|
size="lg"
|
||||||
className="rounded-full h-14 px-8 text-lg font-bold shadow-sm transition-all duration-[var(--sumi-duration-normal)] bg-primary text-primary-foreground"
|
className="rounded-full h-14 px-8 text-lg font-bold shadow-sm transition-all duration-[var(--sumi-duration-normal)] bg-primary text-primary-foreground"
|
||||||
>
|
>
|
||||||
<Play className="w-5 h-5 mr-2 fill-current" /> Play All
|
<Play className="w-5 h-5 mr-2 fill-current" /> {t('playlists.shared.playAll')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="rounded-full h-14 px-6 border-white/10 hover:bg-white/5 backdrop-blur-sm transition-colors duration-[var(--duration-fast)]"
|
className="rounded-full h-14 px-6 border-white/10 hover:bg-white/5 backdrop-blur-sm transition-colors duration-[var(--duration-fast)]"
|
||||||
>
|
>
|
||||||
<Shuffle className="w-5 h-5 mr-2" /> Shuffle
|
<Shuffle className="w-5 h-5 mr-2" /> {t('playlists.shared.shuffle')}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { Music, Calendar, Users } from 'lucide-react';
|
import { Music, Calendar, Users } from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format, isValid } from 'date-fns';
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
import { Avatar } from '@/components/ui/avatar';
|
import { Avatar } from '@/components/ui/avatar';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import type { Playlist } from '../../types';
|
import type { Playlist } from '../../types';
|
||||||
|
|
||||||
interface PlaylistDetailPageCoverAndInfoProps {
|
interface PlaylistDetailPageCoverAndInfoProps {
|
||||||
|
|
@ -10,7 +11,11 @@ interface PlaylistDetailPageCoverAndInfoProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlaylistDetailPageCoverAndInfo({ playlist }: PlaylistDetailPageCoverAndInfoProps) {
|
export function PlaylistDetailPageCoverAndInfo({ playlist }: PlaylistDetailPageCoverAndInfoProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const followerCount = (playlist as { follower_count?: number }).follower_count ?? 0;
|
const followerCount = (playlist as { follower_count?: number }).follower_count ?? 0;
|
||||||
|
const updatedDate = new Date(playlist.updated_at);
|
||||||
|
const formattedDate = isValid(updatedDate) ? format(updatedDate, 'MMM d, yyyy') : '';
|
||||||
|
const username = playlist.user?.username ?? '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col md:flex-row gap-8 items-end">
|
<div className="flex flex-col md:flex-row gap-8 items-end">
|
||||||
|
|
@ -41,11 +46,13 @@ export function PlaylistDetailPageCoverAndInfo({ playlist }: PlaylistDetailPageC
|
||||||
: 'bg-warning/10 text-warning border-warning/20'
|
: 'bg-warning/10 text-warning border-warning/20'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{playlist.is_public ? 'Public Signal' : 'Encrypted'}
|
{playlist.is_public ? t('playlists.shared.publicSignal') : t('playlists.shared.encrypted')}
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground/80 font-mono flex items-center gap-1">
|
|
||||||
<Calendar className="w-3 h-3" /> Updated {format(new Date(playlist.updated_at), 'MMM d, yyyy')}
|
|
||||||
</span>
|
</span>
|
||||||
|
{formattedDate && (
|
||||||
|
<span className="text-xs text-muted-foreground/80 font-mono flex items-center gap-1">
|
||||||
|
<Calendar className="w-3 h-3" /> {t('playlists.shared.updated', { date: formattedDate })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-4xl md:text-6xl font-heading font-bold text-foreground mb-4 tracking-tight drop-shadow-lg">
|
<h1 className="text-4xl md:text-6xl font-heading font-bold text-foreground mb-4 tracking-tight drop-shadow-lg">
|
||||||
|
|
@ -62,17 +69,17 @@ export function PlaylistDetailPageCoverAndInfo({ playlist }: PlaylistDetailPageC
|
||||||
<div className="flex items-center gap-2 text-white/90">
|
<div className="flex items-center gap-2 text-white/90">
|
||||||
<Avatar
|
<Avatar
|
||||||
className="w-6 h-6 border border-white/20"
|
className="w-6 h-6 border border-white/20"
|
||||||
fallback="U"
|
fallback={username.charAt(0).toUpperCase() || '?'}
|
||||||
src={playlist.user?.avatar_url}
|
src={playlist.user?.avatar_url}
|
||||||
/>
|
/>
|
||||||
<span className="font-semibold">{playlist.user?.username}</span>
|
<span className="font-semibold">{username}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-white/30">•</span>
|
<span className="text-white/30">•</span>
|
||||||
<span className="text-white/80">{playlist.track_count} tracks</span>
|
<span className="text-white/80">{playlist.track_count} {t('playlists.tracks')}</span>
|
||||||
<span className="text-white/30">•</span>
|
<span className="text-white/30">•</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Users className="w-4 h-4 text-muted-foreground" />
|
<Users className="w-4 h-4 text-muted-foreground" />
|
||||||
<span className="text-white/80">{followerCount} followers</span>
|
<span className="text-white/80">{t('playlists.shared.followers', { count: followerCount })}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,26 @@ interface PlaylistDetailPageHeroProps {
|
||||||
playlist: Playlist;
|
playlist: Playlist;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSafeUrl(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url, window.location.origin);
|
||||||
|
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function PlaylistDetailPageHero({ playlist }: PlaylistDetailPageHeroProps) {
|
export function PlaylistDetailPageHero({ playlist }: PlaylistDetailPageHeroProps) {
|
||||||
|
const safeUrl = playlist.cover_url && isSafeUrl(playlist.cover_url) ? playlist.cover_url : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-80 md:h-96 w-full overflow-hidden">
|
<div className="relative h-80 md:h-96 w-full overflow-hidden" aria-hidden="true">
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-background to-secondary/30" />
|
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-background to-secondary/30" />
|
||||||
{playlist.cover_url && (
|
{safeUrl && (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 opacity-30 blur-3xl scale-110"
|
className="absolute inset-0 opacity-30 blur-3xl scale-110"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url(${playlist.cover_url})`,
|
backgroundImage: `url(${encodeURI(safeUrl)})`,
|
||||||
backgroundSize: 'cover',
|
backgroundSize: 'cover',
|
||||||
backgroundPosition: 'center',
|
backgroundPosition: 'center',
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,18 @@
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
|
||||||
export function PlaylistDetailPageNotFound() {
|
export function PlaylistDetailPageNotFound() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8 flex flex-col items-center justify-center min-h-layout-page text-center">
|
<div className="container mx-auto px-4 py-8 flex flex-col items-center justify-center min-h-layout-page text-center">
|
||||||
<div className="text-9xl mb-4">👾</div>
|
<div className="text-9xl mb-4">👾</div>
|
||||||
<h2 className="text-3xl font-heading font-bold text-destructive mb-2">
|
<h2 className="text-3xl font-heading font-bold text-destructive mb-2">
|
||||||
Playlist Not Found
|
{t('playlists.shared.notFound')}
|
||||||
</h2>
|
</h2>
|
||||||
<Button variant="outline" className="mt-8" asChild>
|
<Button variant="outline" className="mt-8" asChild>
|
||||||
<Link to="/features/library">Back to Library</Link>
|
<Link to="/library">{t('playlists.shared.backToLibrary')}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Plus, Users, Sparkles, Search } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { PlaylistTrackList } from '../../components/PlaylistTrackList';
|
import { PlaylistTrackList } from '../../components/PlaylistTrackList';
|
||||||
import { AddTrackToPlaylistModal } from '../../components/AddTrackToPlaylistModal';
|
import { AddTrackToPlaylistModal } from '../../components/AddTrackToPlaylistModal';
|
||||||
import { CollaboratorList } from '../../components/CollaboratorList';
|
import { CollaboratorList } from '../../components/CollaboratorList';
|
||||||
|
|
@ -54,6 +55,7 @@ export function PlaylistDetailPageTabs({
|
||||||
onTrackAdded,
|
onTrackAdded,
|
||||||
onCollaboratorAdded,
|
onCollaboratorAdded,
|
||||||
}: PlaylistDetailPageTabsProps) {
|
}: PlaylistDetailPageTabsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Tabs defaultValue="tracks" className="w-full">
|
<Tabs defaultValue="tracks" className="w-full">
|
||||||
|
|
@ -62,21 +64,21 @@ export function PlaylistDetailPageTabs({
|
||||||
value="tracks"
|
value="tracks"
|
||||||
className="rounded-none border-b-2 border-transparent data-[state=active]:bg-transparent data-[state=active]:border-primary data-[state=active]:text-primary py-4 px-0 text-lg font-heading bg-transparent"
|
className="rounded-none border-b-2 border-transparent data-[state=active]:bg-transparent data-[state=active]:border-primary data-[state=active]:text-primary py-4 px-0 text-lg font-heading bg-transparent"
|
||||||
>
|
>
|
||||||
Tracks
|
{t('playlists.tracks')}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
{permissions.canRead && (
|
{permissions.canRead && (
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="collaborators"
|
value="collaborators"
|
||||||
className="rounded-none border-b-2 border-transparent data-[state=active]:bg-transparent data-[state=active]:border-primary data-[state=active]:text-primary py-4 px-0 text-lg font-heading bg-transparent"
|
className="rounded-none border-b-2 border-transparent data-[state=active]:bg-transparent data-[state=active]:border-primary data-[state=active]:text-primary py-4 px-0 text-lg font-heading bg-transparent"
|
||||||
>
|
>
|
||||||
Collaborators
|
{t('playlists.collaborators')}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="recommendations"
|
value="recommendations"
|
||||||
className="rounded-none border-b-2 border-transparent data-[state=active]:bg-transparent data-[state=active]:border-primary data-[state=active]:text-primary py-4 px-0 text-lg font-heading bg-transparent"
|
className="rounded-none border-b-2 border-transparent data-[state=active]:bg-transparent data-[state=active]:border-primary data-[state=active]:text-primary py-4 px-0 text-lg font-heading bg-transparent"
|
||||||
>
|
>
|
||||||
Recommendations
|
{t('playlists.detail.recommendations')}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
|
@ -86,7 +88,8 @@ export function PlaylistDetailPageTabs({
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
<input
|
<input
|
||||||
placeholder="Filter tracks..."
|
placeholder={t('playlists.detail.filterTracks')}
|
||||||
|
aria-label={t('playlists.detail.filterTracks')}
|
||||||
className="bg-transparent border-none text-sm text-foreground placeholder:text-muted-foreground focus:outline-none pl-9 py-2 w-64"
|
className="bg-transparent border-none text-sm text-foreground placeholder:text-muted-foreground focus:outline-none pl-9 py-2 w-64"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -97,7 +100,7 @@ export function PlaylistDetailPageTabs({
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-primary hover:text-primary hover:bg-primary/10"
|
className="text-primary hover:text-primary hover:bg-primary/10"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-2" /> Add Tracks
|
<Plus className="w-4 h-4 mr-2" /> {t('playlists.detail.addTracks')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -121,11 +124,11 @@ export function PlaylistDetailPageTabs({
|
||||||
<Card variant="glass" className="p-6">
|
<Card variant="glass" className="p-6">
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h3 className="text-xl font-bold flex items-center gap-2">
|
<h3 className="text-xl font-bold flex items-center gap-2">
|
||||||
<Users className="w-5 h-5 text-primary" /> Squad Members
|
<Users className="w-5 h-5 text-primary" /> {t('playlists.detail.squadMembers')}
|
||||||
</h3>
|
</h3>
|
||||||
{permissions.canManageCollaborators && (
|
{permissions.canManageCollaborators && (
|
||||||
<Button onClick={() => setIsAddCollaboratorModalOpen(true)}>
|
<Button onClick={() => setIsAddCollaboratorModalOpen(true)}>
|
||||||
<Plus className="w-4 h-4 mr-2" /> Invite
|
<Plus className="w-4 h-4 mr-2" /> {t('playlists.detail.invite')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -141,7 +144,7 @@ export function PlaylistDetailPageTabs({
|
||||||
<div className="bg-gradient-to-br from-primary/10 to-transparent p-6 rounded-2xl border border-primary/20">
|
<div className="bg-gradient-to-br from-primary/10 to-transparent p-6 rounded-2xl border border-primary/20">
|
||||||
<div className="flex items-center gap-2 mb-6">
|
<div className="flex items-center gap-2 mb-6">
|
||||||
<Sparkles className="w-5 h-5 text-yellow-400 animate-pulse" />
|
<Sparkles className="w-5 h-5 text-yellow-400 animate-pulse" />
|
||||||
<h3 className="text-xl font-bold">Suggested for you</h3>
|
<h3 className="text-xl font-bold">{t('playlists.detail.suggestedForYou')}</h3>
|
||||||
</div>
|
</div>
|
||||||
<PlaylistRecommendations
|
<PlaylistRecommendations
|
||||||
limit={8}
|
limit={8}
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,12 @@ import { usePlaylist } from '../../hooks/usePlaylist';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { playlistsApi } from '@/services/api/playlists';
|
import { playlistsApi } from '@/services/api/playlists';
|
||||||
import { usePlaylistPermissions } from '../../hooks/usePlaylistPermissions';
|
import { usePlaylistPermissions } from '../../hooks/usePlaylistPermissions';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import toast from '@/utils/toast';
|
import toast from '@/utils/toast';
|
||||||
import type { Track } from '../../types';
|
import type { Track } from '../../types';
|
||||||
|
|
||||||
export function usePlaylistDetailPage(playlistIdOverride?: string) {
|
export function usePlaylistDetailPage(playlistIdOverride?: string) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { id: paramId } = useParams<{ id: string }>();
|
const { id: paramId } = useParams<{ id: string }>();
|
||||||
const id = playlistIdOverride ?? paramId ?? '';
|
const id = playlistIdOverride ?? paramId ?? '';
|
||||||
|
|
||||||
|
|
@ -30,15 +32,15 @@ export function usePlaylistDetailPage(playlistIdOverride?: string) {
|
||||||
const handleTrackAdded = () => {
|
const handleTrackAdded = () => {
|
||||||
setIsAddTrackModalOpen(false);
|
setIsAddTrackModalOpen(false);
|
||||||
refetch();
|
refetch();
|
||||||
toast.success('Track added');
|
toast.success(t('playlists.detail.trackAdded'));
|
||||||
};
|
};
|
||||||
const handleTrackRemoved = () => {
|
const handleTrackRemoved = () => {
|
||||||
refetch();
|
refetch();
|
||||||
toast.success('Track removed');
|
toast.success(t('playlists.detail.trackRemoved'));
|
||||||
};
|
};
|
||||||
const handleTracksReordered = () => {
|
const handleTracksReordered = () => {
|
||||||
refetch();
|
refetch();
|
||||||
toast.success('Reordered');
|
toast.success(t('playlists.detail.reordered'));
|
||||||
};
|
};
|
||||||
const openShareModal = () => setIsShareModalOpen(true);
|
const openShareModal = () => setIsShareModalOpen(true);
|
||||||
const openAddCollaboratorModal = () => setIsAddCollaboratorModalOpen(true);
|
const openAddCollaboratorModal = () => setIsAddCollaboratorModalOpen(true);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
import { Routes, Route, Navigate, useParams } from 'react-router-dom';
|
||||||
import { PlaylistListPage } from './pages/PlaylistListPage';
|
import { PlaylistListPage } from './pages/PlaylistListPage';
|
||||||
import { PlaylistDetailPage } from './pages/PlaylistDetailPage';
|
import { PlaylistDetailPage } from './pages/PlaylistDetailPage';
|
||||||
import { FavorisRedirectPage } from './pages/FavorisRedirectPage';
|
import { FavorisRedirectPage } from './pages/FavorisRedirectPage';
|
||||||
|
|
||||||
|
function PlaylistEditRedirect() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
return <Navigate to={`/playlists/${id}`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
export function PlaylistRoutes() {
|
export function PlaylistRoutes() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|
@ -10,10 +15,7 @@ export function PlaylistRoutes() {
|
||||||
<Route path="/favoris" element={<FavorisRedirectPage />} />
|
<Route path="/favoris" element={<FavorisRedirectPage />} />
|
||||||
<Route path="/new" element={<Navigate to="/playlists" replace />} />
|
<Route path="/new" element={<Navigate to="/playlists" replace />} />
|
||||||
<Route path="/:id" element={<PlaylistDetailPage />} />
|
<Route path="/:id" element={<PlaylistDetailPage />} />
|
||||||
<Route
|
<Route path="/:id/edit" element={<PlaylistEditRedirect />} />
|
||||||
path="/:id/edit"
|
|
||||||
element={<Navigate to="/playlists/:id" replace />}
|
|
||||||
/>
|
|
||||||
<Route path="*" element={<Navigate to="/playlists" replace />} />
|
<Route path="*" element={<Navigate to="/playlists" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -87,10 +87,15 @@ export async function createPlaylist(
|
||||||
*/
|
*/
|
||||||
export async function getPlaylist(id: string): Promise<Playlist> {
|
export async function getPlaylist(id: string): Promise<Playlist> {
|
||||||
return wrapPlaylistError(async () => {
|
return wrapPlaylistError(async () => {
|
||||||
const response = await apiClient.get<{ playlist: Playlist }>(
|
const response = await apiClient.get<Playlist>(`/playlists/${id}`);
|
||||||
`/playlists/${id}`,
|
// After interceptor unwrapping, response.data is the inner data object.
|
||||||
);
|
// Backend returns playlist flat in data (not nested under data.playlist).
|
||||||
return response.data.playlist;
|
const raw = response.data as Record<string, unknown>;
|
||||||
|
// Handle both shapes: { id, title, ... } (flat) and { playlist: { ... } } (wrapped)
|
||||||
|
if (raw.playlist && typeof raw.playlist === 'object') {
|
||||||
|
return raw.playlist as Playlist;
|
||||||
|
}
|
||||||
|
return response.data as Playlist;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,10 +105,11 @@ export async function getPlaylist(id: string): Promise<Playlist> {
|
||||||
*/
|
*/
|
||||||
export async function getPlaylistByShareToken(token: string): Promise<Playlist> {
|
export async function getPlaylistByShareToken(token: string): Promise<Playlist> {
|
||||||
return wrapPlaylistError(async () => {
|
return wrapPlaylistError(async () => {
|
||||||
const response = await apiClient.get<{ playlist: Playlist }>(
|
const response = await apiClient.get<Playlist>(
|
||||||
`/playlists/shared/${token}`,
|
`/playlists/shared/${token}`,
|
||||||
);
|
);
|
||||||
return response.data.playlist;
|
// Backend returns playlist directly in data (not nested under data.playlist)
|
||||||
|
return response.data;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue