feat: translate admin panel to French

Translates all user-visible text in the admin panel (login, nav,
products, services, schedule, bookings, inquiries, and the shared
friendlyError helper) to plain, direct French for Mira's internal
use. Database CHECK-constraint enum values (product category,
booking/inquiry status) and column names are left untouched in code;
French display labels are added via lookup maps instead. Corresponding
test assertions updated to match.
This commit is contained in:
belviskhoremk
2026-08-12 18:51:53 +02:00
parent b84db38096
commit 3eb361f57f
17 changed files with 123 additions and 105 deletions

View File

@@ -9,7 +9,7 @@ describe('friendlyError', () => {
message: message:
'update or delete on table "products" violates foreign key constraint "inquiries_product_id_fkey" on table "inquiries"', 'update or delete on table "products" violates foreign key constraint "inquiries_product_id_fkey" on table "inquiries"',
}) })
).toBe("This can't be deleted because it's referenced by an existing booking or inquiry.") ).toBe('Impossible de supprimer cet élément : il est référencé par une réservation ou une demande existante.')
}) })
it('falls back to the raw message for any other error', () => { it('falls back to the raw message for any other error', () => {

View File

@@ -8,7 +8,7 @@ const FOREIGN_KEY_VIOLATION = '23503'
export function friendlyError(error: { code?: string; message: string }): string { export function friendlyError(error: { code?: string; message: string }): string {
if (error.code === FOREIGN_KEY_VIOLATION) { if (error.code === FOREIGN_KEY_VIOLATION) {
return "This can't be deleted because it's referenced by an existing booking or inquiry." return "Impossible de supprimer cet élément : il est référencé par une réservation ou une demande existante."
} }
return error.message return error.message
} }

View File

@@ -25,7 +25,7 @@ describe('AdminApp routing', () => {
</Routes> </Routes>
</MemoryRouter>, </MemoryRouter>,
) )
expect(screen.getByRole('heading', { name: /admin login/i })).toBeInTheDocument() expect(screen.getByRole('heading', { name: /connexion administrateur/i })).toBeInTheDocument()
}) })
it('renders the products page (with layout) at /admin/products when authenticated', async () => { it('renders the products page (with layout) at /admin/products when authenticated', async () => {
@@ -36,8 +36,8 @@ describe('AdminApp routing', () => {
</Routes> </Routes>
</MemoryRouter>, </MemoryRouter>,
) )
expect(await screen.findByRole('heading', { name: /^products$/i })).toBeInTheDocument() expect(await screen.findByRole('heading', { name: /^produits$/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /bookings/i })).toBeInTheDocument() expect(screen.getByRole('link', { name: /réservations/i })).toBeInTheDocument()
}) })
it('redirects /admin to /admin/products when authenticated', async () => { it('redirects /admin to /admin/products when authenticated', async () => {
@@ -48,7 +48,7 @@ describe('AdminApp routing', () => {
</Routes> </Routes>
</MemoryRouter>, </MemoryRouter>,
) )
expect(await screen.findByRole('heading', { name: /^products$/i })).toBeInTheDocument() expect(await screen.findByRole('heading', { name: /^produits$/i })).toBeInTheDocument()
}) })
it('redirects an unmatched /admin/* path to /admin/products when authenticated', async () => { it('redirects an unmatched /admin/* path to /admin/products when authenticated', async () => {
@@ -59,6 +59,6 @@ describe('AdminApp routing', () => {
</Routes> </Routes>
</MemoryRouter>, </MemoryRouter>,
) )
expect(await screen.findByRole('heading', { name: /^products$/i })).toBeInTheDocument() expect(await screen.findByRole('heading', { name: /^produits$/i })).toBeInTheDocument()
}) })
}) })

View File

@@ -47,7 +47,7 @@ describe('AdminBookings', () => {
render(<AdminBookings />) render(<AdminBookings />)
await screen.findByText('Jane Doe') await screen.findByText('Jane Doe')
await userEvent.click(screen.getByRole('button', { name: /cancel/i })) await userEvent.click(screen.getByRole('button', { name: /annuler/i }))
expect(window.confirm).toHaveBeenCalled() expect(window.confirm).toHaveBeenCalled()
await waitFor(() => expect(updateMock).toHaveBeenCalledWith({ status: 'cancelled' })) await waitFor(() => expect(updateMock).toHaveBeenCalledWith({ status: 'cancelled' }))
@@ -59,7 +59,7 @@ describe('AdminBookings', () => {
render(<AdminBookings />) render(<AdminBookings />)
await screen.findByText('Jane Doe') await screen.findByText('Jane Doe')
await userEvent.click(screen.getByRole('button', { name: /cancel/i })) await userEvent.click(screen.getByRole('button', { name: /annuler/i }))
expect(window.confirm).toHaveBeenCalled() expect(window.confirm).toHaveBeenCalled()
expect(updateMock).not.toHaveBeenCalled() expect(updateMock).not.toHaveBeenCalled()
@@ -69,7 +69,7 @@ describe('AdminBookings', () => {
render(<AdminBookings />) render(<AdminBookings />)
await screen.findByText('Jane Doe') await screen.findByText('Jane Doe')
await userEvent.click(screen.getByRole('button', { name: /mark completed/i })) await userEvent.click(screen.getByRole('button', { name: /marquer comme terminée/i }))
expect(window.confirm).toHaveBeenCalled() expect(window.confirm).toHaveBeenCalled()
await waitFor(() => expect(updateMock).toHaveBeenCalledWith({ status: 'completed' })) await waitFor(() => expect(updateMock).toHaveBeenCalledWith({ status: 'completed' }))
@@ -80,7 +80,7 @@ describe('AdminBookings', () => {
render(<AdminBookings />) render(<AdminBookings />)
await screen.findByText('Jane Doe') await screen.findByText('Jane Doe')
await userEvent.click(screen.getByRole('button', { name: /mark completed/i })) await userEvent.click(screen.getByRole('button', { name: /marquer comme terminée/i }))
expect(window.confirm).toHaveBeenCalled() expect(window.confirm).toHaveBeenCalled()
expect(updateMock).not.toHaveBeenCalled() expect(updateMock).not.toHaveBeenCalled()

View File

@@ -14,6 +14,12 @@ interface AdminBooking {
services: { name: string } | null services: { name: string } | null
} }
const STATUS_LABELS: Record<AdminBooking['status'], string> = {
confirmed: 'Confirmée',
cancelled: 'Annulée',
completed: 'Terminée',
}
function AdminBookings() { function AdminBookings() {
const [bookings, setBookings] = useState<AdminBooking[]>([]) const [bookings, setBookings] = useState<AdminBooking[]>([])
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -56,16 +62,16 @@ function AdminBookings() {
return ( return (
<div className={styles.page}> <div className={styles.page}>
<h1>Bookings</h1> <h1>Réservations</h1>
{error && <p className={styles.error}>{error}</p>} {error && <p className={styles.error}>{error}</p>}
<table className={styles.table}> <table className={styles.table}>
<thead> <thead>
<tr> <tr>
<th>Date</th> <th>Date</th>
<th>Time</th> <th>Heure</th>
<th>Service</th> <th>Service</th>
<th>Contact</th> <th>Contact</th>
<th>Status</th> <th>Statut</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -81,29 +87,29 @@ function AdminBookings() {
<div>{booking.contact_email}</div> <div>{booking.contact_email}</div>
{booking.contact_notes && <div>{booking.contact_notes}</div>} {booking.contact_notes && <div>{booking.contact_notes}</div>}
</td> </td>
<td>{booking.status}</td> <td>{STATUS_LABELS[booking.status]}</td>
<td> <td>
{booking.status === 'confirmed' && ( {booking.status === 'confirmed' && (
<> <>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
if (window.confirm('Cancel this booking? This will free up its slot for other bookings.')) { if (window.confirm('Annuler cette réservation ? Cela libérera son créneau pour d\'autres réservations.')) {
void setStatus(booking.id, 'cancelled') void setStatus(booking.id, 'cancelled')
} }
}} }}
> >
Cancel Annuler
</button> </button>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
if (window.confirm('Mark this booking completed?')) { if (window.confirm('Marquer cette réservation comme terminée ?')) {
void setStatus(booking.id, 'completed') void setStatus(booking.id, 'completed')
} }
}} }}
> >
Mark completed Marquer comme terminée
</button> </button>
</> </>
)} )}

View File

@@ -43,7 +43,7 @@ describe('AdminInquiries', () => {
render(<AdminInquiries />) render(<AdminInquiries />)
await screen.findByText('Nadia') await screen.findByText('Nadia')
await userEvent.click(screen.getByRole('button', { name: /mark as read/i })) await userEvent.click(screen.getByRole('button', { name: /marquer comme lue/i }))
await waitFor(() => expect(updateMock).toHaveBeenCalledWith({ status: 'read' })) await waitFor(() => expect(updateMock).toHaveBeenCalledWith({ status: 'read' }))
expect(eqUpdateMock).toHaveBeenCalledWith('id', 'inquiry-1') expect(eqUpdateMock).toHaveBeenCalledWith('id', 'inquiry-1')

View File

@@ -11,6 +11,11 @@ interface AdminInquiry {
products: { name: string } | null products: { name: string } | null
} }
const STATUS_LABELS: Record<AdminInquiry['status'], string> = {
new: 'Nouvelle',
read: 'Lue',
}
function AdminInquiries() { function AdminInquiries() {
const [inquiries, setInquiries] = useState<AdminInquiry[]>([]) const [inquiries, setInquiries] = useState<AdminInquiry[]>([])
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -53,15 +58,15 @@ function AdminInquiries() {
return ( return (
<div className={styles.page}> <div className={styles.page}>
<h1>Inquiries</h1> <h1>Demandes</h1>
{error && <p className={styles.error}>{error}</p>} {error && <p className={styles.error}>{error}</p>}
<table className={styles.table}> <table className={styles.table}>
<thead> <thead>
<tr> <tr>
<th>Product</th> <th>Produit</th>
<th>Contact</th> <th>Contact</th>
<th>Notes</th> <th>Notes</th>
<th>Status</th> <th>Statut</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -74,10 +79,10 @@ function AdminInquiries() {
<div>{inquiry.contact_email}</div> <div>{inquiry.contact_email}</div>
</td> </td>
<td>{inquiry.contact_notes}</td> <td>{inquiry.contact_notes}</td>
<td>{inquiry.status}</td> <td>{STATUS_LABELS[inquiry.status]}</td>
<td> <td>
{inquiry.status === 'new' && ( {inquiry.status === 'new' && (
<button type="button" onClick={() => markRead(inquiry.id)}>Mark as read</button> <button type="button" onClick={() => markRead(inquiry.id)}>Marquer comme lue</button>
)} )}
</td> </td>
</tr> </tr>

View File

@@ -37,17 +37,17 @@ describe('AdminLayout', () => {
it('renders nav links for every admin section and the outlet content', () => { it('renders nav links for every admin section and the outlet content', () => {
renderLayout() renderLayout()
expect(screen.getByRole('link', { name: /products/i })).toBeInTheDocument() expect(screen.getByRole('link', { name: /produits/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /services/i })).toBeInTheDocument() expect(screen.getByRole('link', { name: /services/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /schedule/i })).toBeInTheDocument() expect(screen.getByRole('link', { name: /horaire/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /bookings/i })).toBeInTheDocument() expect(screen.getByRole('link', { name: /réservations/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /inquiries/i })).toBeInTheDocument() expect(screen.getByRole('link', { name: /demandes/i })).toBeInTheDocument()
expect(screen.getByText('Products content')).toBeInTheDocument() expect(screen.getByText('Products content')).toBeInTheDocument()
}) })
it('signs out and navigates to the login page', async () => { it('signs out and navigates to the login page', async () => {
renderLayout() renderLayout()
await userEvent.click(screen.getByRole('button', { name: /sign out/i })) await userEvent.click(screen.getByRole('button', { name: /déconnexion/i }))
expect(signOutMock).toHaveBeenCalled() expect(signOutMock).toHaveBeenCalled()
expect(navigateMock).toHaveBeenCalledWith('/admin/login') expect(navigateMock).toHaveBeenCalledWith('/admin/login')
}) })
@@ -55,7 +55,7 @@ describe('AdminLayout', () => {
it('does not navigate if sign out fails', async () => { it('does not navigate if sign out fails', async () => {
signOutMock.mockResolvedValue({ error: new Error('Sign out failed') }) signOutMock.mockResolvedValue({ error: new Error('Sign out failed') })
renderLayout() renderLayout()
await userEvent.click(screen.getByRole('button', { name: /sign out/i })) await userEvent.click(screen.getByRole('button', { name: /déconnexion/i }))
expect(signOutMock).toHaveBeenCalled() expect(signOutMock).toHaveBeenCalled()
expect(navigateMock).not.toHaveBeenCalled() expect(navigateMock).not.toHaveBeenCalled()
}) })

View File

@@ -3,11 +3,11 @@ import { supabase } from '../../lib/supabase'
import styles from './AdminLayout.module.css' import styles from './AdminLayout.module.css'
const NAV_ITEMS = [ const NAV_ITEMS = [
{ to: '/admin/products', label: 'Products' }, { to: '/admin/products', label: 'Produits' },
{ to: '/admin/services', label: 'Services' }, { to: '/admin/services', label: 'Services' },
{ to: '/admin/schedule', label: 'Schedule' }, { to: '/admin/schedule', label: 'Horaire' },
{ to: '/admin/bookings', label: 'Bookings' }, { to: '/admin/bookings', label: 'Réservations' },
{ to: '/admin/inquiries', label: 'Inquiries' }, { to: '/admin/inquiries', label: 'Demandes' },
] ]
function AdminLayout() { function AdminLayout() {
@@ -34,7 +34,7 @@ function AdminLayout() {
))} ))}
</nav> </nav>
<button type="button" className={styles.signOut} onClick={handleSignOut}> <button type="button" className={styles.signOut} onClick={handleSignOut}>
Sign out Déconnexion
</button> </button>
</aside> </aside>
<main className={styles.content}> <main className={styles.content}>

View File

@@ -27,9 +27,9 @@ describe('AdminLogin', () => {
signInMock.mockResolvedValue({ error: null }) signInMock.mockResolvedValue({ error: null })
render(<MemoryRouter><AdminLogin /></MemoryRouter>) render(<MemoryRouter><AdminLogin /></MemoryRouter>)
await userEvent.type(screen.getByLabelText(/email/i), 'mira@example.com') await userEvent.type(screen.getByLabelText(/e-mail/i), 'mira@example.com')
await userEvent.type(screen.getByLabelText(/password/i), 'correct-password') await userEvent.type(screen.getByLabelText(/mot de passe/i), 'correct-password')
await userEvent.click(screen.getByRole('button', { name: /sign in/i })) await userEvent.click(screen.getByRole('button', { name: /se connecter/i }))
expect(signInMock).toHaveBeenCalledWith({ email: 'mira@example.com', password: 'correct-password' }) expect(signInMock).toHaveBeenCalledWith({ email: 'mira@example.com', password: 'correct-password' })
expect(navigateMock).toHaveBeenCalledWith('/admin/products') expect(navigateMock).toHaveBeenCalledWith('/admin/products')
@@ -39,11 +39,11 @@ describe('AdminLogin', () => {
signInMock.mockResolvedValue({ error: { message: 'Invalid login credentials' } }) signInMock.mockResolvedValue({ error: { message: 'Invalid login credentials' } })
render(<MemoryRouter><AdminLogin /></MemoryRouter>) render(<MemoryRouter><AdminLogin /></MemoryRouter>)
await userEvent.type(screen.getByLabelText(/email/i), 'mira@example.com') await userEvent.type(screen.getByLabelText(/e-mail/i), 'mira@example.com')
await userEvent.type(screen.getByLabelText(/password/i), 'wrong-password') await userEvent.type(screen.getByLabelText(/mot de passe/i), 'wrong-password')
await userEvent.click(screen.getByRole('button', { name: /sign in/i })) await userEvent.click(screen.getByRole('button', { name: /se connecter/i }))
expect(await screen.findByText(/incorrect email or password/i)).toBeInTheDocument() expect(await screen.findByText(/adresse e-mail ou mot de passe incorrect/i)).toBeInTheDocument()
expect(navigateMock).not.toHaveBeenCalled() expect(navigateMock).not.toHaveBeenCalled()
}) })
}) })

View File

@@ -17,7 +17,7 @@ function AdminLogin() {
const { error: signInError } = await supabase.auth.signInWithPassword({ email, password }) const { error: signInError } = await supabase.auth.signInWithPassword({ email, password })
setSubmitting(false) setSubmitting(false)
if (signInError) { if (signInError) {
setError('Incorrect email or password.') setError('Adresse e-mail ou mot de passe incorrect.')
return return
} }
navigate('/admin/products') navigate('/admin/products')
@@ -26,9 +26,9 @@ function AdminLogin() {
return ( return (
<div className={styles.page}> <div className={styles.page}>
<form onSubmit={handleSubmit} className={styles.form}> <form onSubmit={handleSubmit} className={styles.form}>
<h1>Admin Login</h1> <h1>Connexion administrateur</h1>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="admin-email">Email</label> <label htmlFor="admin-email">E-mail</label>
<input <input
id="admin-email" id="admin-email"
type="email" type="email"
@@ -38,7 +38,7 @@ function AdminLogin() {
/> />
</div> </div>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="admin-password">Password</label> <label htmlFor="admin-password">Mot de passe</label>
<input <input
id="admin-password" id="admin-password"
type="password" type="password"
@@ -49,7 +49,7 @@ function AdminLogin() {
</div> </div>
{error && <p className={styles.error}>{error}</p>} {error && <p className={styles.error}>{error}</p>}
<button type="submit" disabled={submitting}> <button type="submit" disabled={submitting}>
{submitting ? 'Signing in…' : 'Sign in'} {submitting ? 'Connexion…' : 'Se connecter'}
</button> </button>
</form> </form>
</div> </div>

View File

@@ -58,10 +58,10 @@ describe('AdminProducts', () => {
await screen.findByText('Silk Edge Control') await screen.findByText('Silk Edge Control')
await userEvent.type(screen.getByLabelText(/^id$/i), 'braid-oil') await userEvent.type(screen.getByLabelText(/^id$/i), 'braid-oil')
await userEvent.type(screen.getByLabelText(/^name$/i), 'Nourishing Braid Oil') await userEvent.type(screen.getByLabelText(/^nom$/i), 'Nourishing Braid Oil')
await userEvent.type(screen.getByLabelText(/^price$/i), '16') await userEvent.type(screen.getByLabelText(/^prix$/i), '16')
await userEvent.type(screen.getByLabelText(/^description$/i), 'Lightweight scalp oil.') await userEvent.type(screen.getByLabelText(/^description$/i), 'Lightweight scalp oil.')
await userEvent.click(screen.getByRole('button', { name: /save product/i })) await userEvent.click(screen.getByRole('button', { name: /enregistrer le produit/i }))
await waitFor(() => await waitFor(() =>
expect(upsertMock).toHaveBeenCalledWith( expect(upsertMock).toHaveBeenCalledWith(
@@ -75,7 +75,7 @@ describe('AdminProducts', () => {
render(<AdminProducts />) render(<AdminProducts />)
await screen.findByText('Silk Edge Control') await screen.findByText('Silk Edge Control')
await userEvent.click(screen.getByRole('button', { name: /delete/i })) await userEvent.click(screen.getByRole('button', { name: /supprimer/i }))
expect(deleteMock).toHaveBeenCalled() expect(deleteMock).toHaveBeenCalled()
expect(eqMock).toHaveBeenCalledWith('id', 'edge-control') expect(eqMock).toHaveBeenCalledWith('id', 'edge-control')

View File

@@ -16,6 +16,13 @@ interface AdminProduct {
const CATEGORIES: AdminProduct['category'][] = ['hair-care', 'extensions', 'tools', 'accessories'] const CATEGORIES: AdminProduct['category'][] = ['hair-care', 'extensions', 'tools', 'accessories']
const CATEGORY_LABELS: Record<AdminProduct['category'], string> = {
'hair-care': 'Soins capillaires',
extensions: 'Extensions',
tools: 'Outils',
accessories: 'Accessoires',
}
function emptyForm(): AdminProduct { function emptyForm(): AdminProduct {
return { id: '', name: '', category: 'hair-care', price: 0, image: '', description: '', in_stock: true } return { id: '', name: '', category: 'hair-care', price: 0, image: '', description: '', in_stock: true }
} }
@@ -58,7 +65,7 @@ function AdminProducts() {
try { try {
image = await uploadSalonImage(file, `products/${form.id}-${file.name}`) image = await uploadSalonImage(file, `products/${form.id}-${file.name}`)
} catch { } catch {
setError('Image upload failed.') setError("Échec du téléversement de l'image.")
setSaving(false) setSaving(false)
return return
} }
@@ -76,7 +83,7 @@ function AdminProducts() {
} }
async function handleDelete(id: string) { async function handleDelete(id: string) {
if (!window.confirm('Delete this product?')) return if (!window.confirm('Supprimer ce produit ?')) return
const { error: deleteError } = await supabase.from('products').delete().eq('id', id) const { error: deleteError } = await supabase.from('products').delete().eq('id', id)
if (deleteError) { if (deleteError) {
setError(friendlyError(deleteError)) setError(friendlyError(deleteError))
@@ -89,14 +96,14 @@ function AdminProducts() {
return ( return (
<div className={styles.page}> <div className={styles.page}>
<h1>Products</h1> <h1>Produits</h1>
<table className={styles.table}> <table className={styles.table}>
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Nom</th>
<th>Category</th> <th>Catégorie</th>
<th>Price</th> <th>Prix</th>
<th>In stock</th> <th>En stock</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -104,12 +111,12 @@ function AdminProducts() {
{products.map((product) => ( {products.map((product) => (
<tr key={product.id}> <tr key={product.id}>
<td>{product.name}</td> <td>{product.name}</td>
<td>{product.category}</td> <td>{CATEGORY_LABELS[product.category]}</td>
<td>${product.price}</td> <td>{product.price} $</td>
<td>{product.in_stock ? 'Yes' : 'No'}</td> <td>{product.in_stock ? 'Oui' : 'Non'}</td>
<td> <td>
<button type="button" onClick={() => setForm(product)}>Edit</button> <button type="button" onClick={() => setForm(product)}>Modifier</button>
<button type="button" onClick={() => handleDelete(product.id)}>Delete</button> <button type="button" onClick={() => handleDelete(product.id)}>Supprimer</button>
</td> </td>
</tr> </tr>
))} ))}
@@ -117,7 +124,7 @@ function AdminProducts() {
</table> </table>
<form className={styles.form} onSubmit={handleSubmit}> <form className={styles.form} onSubmit={handleSubmit}>
<h2>{isEditing ? 'Edit product' : 'New product'}</h2> <h2>{isEditing ? 'Modifier le produit' : 'Nouveau produit'}</h2>
<label htmlFor="product-id"> <label htmlFor="product-id">
Id Id
<input <input
@@ -129,7 +136,7 @@ function AdminProducts() {
/> />
</label> </label>
<label htmlFor="product-name"> <label htmlFor="product-name">
Name Nom
<input <input
id="product-name" id="product-name"
value={form.name} value={form.name}
@@ -138,19 +145,19 @@ function AdminProducts() {
/> />
</label> </label>
<label htmlFor="product-category"> <label htmlFor="product-category">
Category Catégorie
<select <select
id="product-category" id="product-category"
value={form.category} value={form.category}
onChange={(e) => setForm((f) => ({ ...f, category: e.target.value as AdminProduct['category'] }))} onChange={(e) => setForm((f) => ({ ...f, category: e.target.value as AdminProduct['category'] }))}
> >
{CATEGORIES.map((category) => ( {CATEGORIES.map((category) => (
<option key={category} value={category}>{category}</option> <option key={category} value={category}>{CATEGORY_LABELS[category]}</option>
))} ))}
</select> </select>
</label> </label>
<label htmlFor="product-price"> <label htmlFor="product-price">
Price Prix
<input <input
id="product-price" id="product-price"
type="number" type="number"
@@ -185,10 +192,10 @@ function AdminProducts() {
checked={form.in_stock} checked={form.in_stock}
onChange={(e) => setForm((f) => ({ ...f, in_stock: e.target.checked }))} onChange={(e) => setForm((f) => ({ ...f, in_stock: e.target.checked }))}
/> />
In stock En stock
</label> </label>
{error && <p className={styles.error}>{error}</p>} {error && <p className={styles.error}>{error}</p>}
<button type="submit" disabled={saving}>Save product</button> <button type="submit" disabled={saving}>Enregistrer le produit</button>
</form> </form>
</div> </div>
) )

View File

@@ -67,7 +67,7 @@ describe('AdminSchedule', () => {
render(<AdminSchedule />) render(<AdminSchedule />)
await screen.findByDisplayValue('09:00:00') await screen.findByDisplayValue('09:00:00')
await userEvent.click(screen.getByRole('button', { name: /save schedule/i })) await userEvent.click(screen.getByRole('button', { name: /enregistrer l'horaire/i }))
await waitFor(() => expect(templateUpdateMock).toHaveBeenCalled()) await waitFor(() => expect(templateUpdateMock).toHaveBeenCalled())
expect(templateEqUpdateMock).toHaveBeenCalledWith('id', 'default') expect(templateEqUpdateMock).toHaveBeenCalledWith('id', 'default')
@@ -77,9 +77,9 @@ describe('AdminSchedule', () => {
render(<AdminSchedule />) render(<AdminSchedule />)
await screen.findByDisplayValue('09:00:00') await screen.findByDisplayValue('09:00:00')
await userEvent.type(screen.getByLabelText(/exception date/i), '2026-11-01') await userEvent.type(screen.getByLabelText(/date d'exception/i), '2026-11-01')
await userEvent.type(screen.getByLabelText(/^note$/i), 'Closed for training') await userEvent.type(screen.getByLabelText(/^note$/i), 'Closed for training')
await userEvent.click(screen.getByRole('button', { name: /add exception/i })) await userEvent.click(screen.getByRole('button', { name: /ajouter une exception/i }))
await waitFor(() => await waitFor(() =>
expect(exceptionsInsertMock).toHaveBeenCalledWith( expect(exceptionsInsertMock).toHaveBeenCalledWith(

View File

@@ -16,7 +16,7 @@ interface ScheduleException {
note: string | null note: string | null
} }
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] const DAY_LABELS = ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam']
function AdminSchedule() { function AdminSchedule() {
const [template, setTemplate] = useState<ScheduleTemplate | null>(null) const [template, setTemplate] = useState<ScheduleTemplate | null>(null)
@@ -115,11 +115,11 @@ function AdminSchedule() {
return ( return (
<div className={styles.page}> <div className={styles.page}>
<h1>Schedule</h1> <h1>Horaire</h1>
{template && ( {template && (
<form className={styles.section} onSubmit={handleSaveTemplate}> <form className={styles.section} onSubmit={handleSaveTemplate}>
<h2>Weekly hours</h2> <h2>Heures hebdomadaires</h2>
<div className={styles.days}> <div className={styles.days}>
{DAY_LABELS.map((label, day) => ( {DAY_LABELS.map((label, day) => (
<label key={day}> <label key={day}>
@@ -133,7 +133,7 @@ function AdminSchedule() {
))} ))}
</div> </div>
<label htmlFor="schedule-start"> <label htmlFor="schedule-start">
Start time Heure de début
<input <input
id="schedule-start" id="schedule-start"
type="time" type="time"
@@ -143,7 +143,7 @@ function AdminSchedule() {
/> />
</label> </label>
<label htmlFor="schedule-end"> <label htmlFor="schedule-end">
End time Heure de fin
<input <input
id="schedule-end" id="schedule-end"
type="time" type="time"
@@ -153,7 +153,7 @@ function AdminSchedule() {
/> />
</label> </label>
<label htmlFor="schedule-slot-minutes"> <label htmlFor="schedule-slot-minutes">
Slot length (minutes) Durée des créneaux (minutes)
<input <input
id="schedule-slot-minutes" id="schedule-slot-minutes"
type="number" type="number"
@@ -161,7 +161,7 @@ function AdminSchedule() {
onChange={(e) => setTemplate({ ...template, slot_minutes: Number(e.target.value) })} onChange={(e) => setTemplate({ ...template, slot_minutes: Number(e.target.value) })}
/> />
</label> </label>
<button type="submit" disabled={saving}>Save schedule</button> <button type="submit" disabled={saving}>Enregistrer l&apos;horaire</button>
</form> </form>
)} )}
@@ -171,7 +171,7 @@ function AdminSchedule() {
<thead> <thead>
<tr> <tr>
<th>Date</th> <th>Date</th>
<th>Closed</th> <th>Fermé</th>
<th>Note</th> <th>Note</th>
<th></th> <th></th>
</tr> </tr>
@@ -180,10 +180,10 @@ function AdminSchedule() {
{exceptions.map((exception) => ( {exceptions.map((exception) => (
<tr key={exception.date}> <tr key={exception.date}>
<td>{exception.date}</td> <td>{exception.date}</td>
<td>{exception.closed ? 'Yes' : 'No'}</td> <td>{exception.closed ? 'Oui' : 'Non'}</td>
<td>{exception.note}</td> <td>{exception.note}</td>
<td> <td>
<button type="button" onClick={() => handleRemoveException(exception.date)}>Remove</button> <button type="button" onClick={() => handleRemoveException(exception.date)}>Retirer</button>
</td> </td>
</tr> </tr>
))} ))}
@@ -192,7 +192,7 @@ function AdminSchedule() {
<form onSubmit={handleAddException} className={styles.form}> <form onSubmit={handleAddException} className={styles.form}>
<label htmlFor="exception-date"> <label htmlFor="exception-date">
Exception date Date d&apos;exception
<input <input
id="exception-date" id="exception-date"
type="date" type="date"
@@ -207,7 +207,7 @@ function AdminSchedule() {
checked={newException.closed} checked={newException.closed}
onChange={(e) => setNewException((f) => ({ ...f, closed: e.target.checked }))} onChange={(e) => setNewException((f) => ({ ...f, closed: e.target.checked }))}
/> />
Closed Fermé
</label> </label>
<label htmlFor="exception-note"> <label htmlFor="exception-note">
Note Note
@@ -217,7 +217,7 @@ function AdminSchedule() {
onChange={(e) => setNewException((f) => ({ ...f, note: e.target.value }))} onChange={(e) => setNewException((f) => ({ ...f, note: e.target.value }))}
/> />
</label> </label>
<button type="submit">Add exception</button> <button type="submit">Ajouter une exception</button>
</form> </form>
</div> </div>

View File

@@ -55,11 +55,11 @@ describe('AdminServices', () => {
await screen.findByText('Knotless Braids') await screen.findByText('Knotless Braids')
await userEvent.type(screen.getByLabelText(/^id$/i), 'cornrows') await userEvent.type(screen.getByLabelText(/^id$/i), 'cornrows')
await userEvent.type(screen.getByLabelText(/^name$/i), 'Cornrows') await userEvent.type(screen.getByLabelText(/^nom$/i), 'Cornrows')
await userEvent.type(screen.getByLabelText(/duration/i), '120') await userEvent.type(screen.getByLabelText(/durée en minutes/i), '120')
await userEvent.type(screen.getByLabelText(/price from/i), '90') await userEvent.type(screen.getByLabelText(/prix à partir de/i), '90')
await userEvent.type(screen.getByLabelText(/^description$/i), 'Classic style.') await userEvent.type(screen.getByLabelText(/^description$/i), 'Classic style.')
await userEvent.click(screen.getByRole('button', { name: /save service/i })) await userEvent.click(screen.getByRole('button', { name: /enregistrer le service/i }))
await waitFor(() => await waitFor(() =>
expect(upsertMock).toHaveBeenCalledWith( expect(upsertMock).toHaveBeenCalledWith(
@@ -73,7 +73,7 @@ describe('AdminServices', () => {
render(<AdminServices />) render(<AdminServices />)
await screen.findByText('Knotless Braids') await screen.findByText('Knotless Braids')
await userEvent.click(screen.getByRole('button', { name: /delete/i })) await userEvent.click(screen.getByRole('button', { name: /supprimer/i }))
expect(deleteMock).toHaveBeenCalled() expect(deleteMock).toHaveBeenCalled()
expect(eqMock).toHaveBeenCalledWith('id', 'knotless') expect(eqMock).toHaveBeenCalledWith('id', 'knotless')

View File

@@ -55,7 +55,7 @@ function AdminServices() {
try { try {
image = await uploadSalonImage(file, `styles/${form.id}-${file.name}`) image = await uploadSalonImage(file, `styles/${form.id}-${file.name}`)
} catch { } catch {
setError('Image upload failed.') setError("Échec du téléversement de l'image.")
setSaving(false) setSaving(false)
return return
} }
@@ -73,7 +73,7 @@ function AdminServices() {
} }
async function handleDelete(id: string) { async function handleDelete(id: string) {
if (!window.confirm('Delete this service?')) return if (!window.confirm('Supprimer ce service ?')) return
const { error: deleteError } = await supabase.from('services').delete().eq('id', id) const { error: deleteError } = await supabase.from('services').delete().eq('id', id)
if (deleteError) { if (deleteError) {
setError(friendlyError(deleteError)) setError(friendlyError(deleteError))
@@ -90,9 +90,9 @@ function AdminServices() {
<table className={styles.table}> <table className={styles.table}>
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Nom</th>
<th>Duration (min)</th> <th>Durée (min)</th>
<th>Price from</th> <th>Prix à partir de</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -101,10 +101,10 @@ function AdminServices() {
<tr key={service.id}> <tr key={service.id}>
<td>{service.name}</td> <td>{service.name}</td>
<td>{service.duration_minutes}</td> <td>{service.duration_minutes}</td>
<td>${service.price_from}</td> <td>{service.price_from} $</td>
<td> <td>
<button type="button" onClick={() => setForm(service)}>Edit</button> <button type="button" onClick={() => setForm(service)}>Modifier</button>
<button type="button" onClick={() => handleDelete(service.id)}>Delete</button> <button type="button" onClick={() => handleDelete(service.id)}>Supprimer</button>
</td> </td>
</tr> </tr>
))} ))}
@@ -112,7 +112,7 @@ function AdminServices() {
</table> </table>
<form className={styles.form} onSubmit={handleSubmit}> <form className={styles.form} onSubmit={handleSubmit}>
<h2>{isEditing ? 'Edit service' : 'New service'}</h2> <h2>{isEditing ? 'Modifier le service' : 'Nouveau service'}</h2>
<label htmlFor="service-id"> <label htmlFor="service-id">
Id Id
<input <input
@@ -124,7 +124,7 @@ function AdminServices() {
/> />
</label> </label>
<label htmlFor="service-name"> <label htmlFor="service-name">
Name Nom
<input <input
id="service-name" id="service-name"
value={form.name} value={form.name}
@@ -133,7 +133,7 @@ function AdminServices() {
/> />
</label> </label>
<label htmlFor="service-duration"> <label htmlFor="service-duration">
Duration minutes Durée en minutes
<input <input
id="service-duration" id="service-duration"
type="number" type="number"
@@ -143,7 +143,7 @@ function AdminServices() {
/> />
</label> </label>
<label htmlFor="service-price-from"> <label htmlFor="service-price-from">
Price from Prix à partir de
<input <input
id="service-price-from" id="service-price-from"
type="number" type="number"
@@ -172,7 +172,7 @@ function AdminServices() {
/> />
</label> </label>
{error && <p className={styles.error}>{error}</p>} {error && <p className={styles.error}>{error}</p>}
<button type="submit" disabled={saving}>Save service</button> <button type="submit" disabled={saving}>Enregistrer le service</button>
</form> </form>
</div> </div>
) )