diff --git a/src/pages/admin/AdminContent.module.css b/src/pages/admin/AdminContent.module.css
new file mode 100644
index 0000000..51ab29a
--- /dev/null
+++ b/src/pages/admin/AdminContent.module.css
@@ -0,0 +1,16 @@
+/* src/pages/admin/AdminContent.module.css */
+.page {
+ composes: page from './adminShared.module.css';
+}
+
+.form {
+ composes: form from './adminShared.module.css';
+}
+
+.button {
+ composes: button from './adminShared.module.css';
+}
+
+.error {
+ composes: error from './adminShared.module.css';
+}
diff --git a/src/pages/admin/AdminContent.test.tsx b/src/pages/admin/AdminContent.test.tsx
new file mode 100644
index 0000000..b093a0b
--- /dev/null
+++ b/src/pages/admin/AdminContent.test.tsx
@@ -0,0 +1,67 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+const singleMock = vi.fn()
+const eqSelectMock = vi.fn(() => ({ single: singleMock }))
+const selectMock = vi.fn(() => ({ eq: eqSelectMock }))
+const eqUpdateMock = vi.fn()
+const updateMock = vi.fn(() => ({ eq: eqUpdateMock }))
+const fromMock = vi.fn<(table: string) => { select: typeof selectMock; update: typeof updateMock }>(() => ({
+ select: selectMock,
+ update: updateMock,
+}))
+const uploadSalonImageMock = vi.fn()
+
+vi.mock('../../lib/supabase', () => ({
+ supabase: { from: (table: string) => fromMock(table) },
+}))
+vi.mock('../../lib/storage', () => ({
+ uploadSalonImage: (...args: unknown[]) => uploadSalonImageMock(...args),
+}))
+
+const CONTENT_ROW = {
+ id: 'default',
+ hero_headline: 'Vos cheveux, votre couronne',
+ hero_tagline: 'Coiffures protectrices par Mira',
+ about_bio: 'Bio actuelle.',
+ about_portrait_image: 'about/mira-portrait.jpg',
+ contact_email: 'hello@braidsbymira.com',
+ contact_hours: 'Mar–Sam, 9h à 18h',
+}
+
+import AdminContent from './AdminContent'
+
+describe('AdminContent', () => {
+ beforeEach(() => {
+ singleMock.mockReset().mockResolvedValue({ data: CONTENT_ROW, error: null })
+ eqSelectMock.mockClear()
+ selectMock.mockClear()
+ eqUpdateMock.mockReset().mockResolvedValue({ error: null })
+ updateMock.mockClear()
+ fromMock.mockClear()
+ uploadSalonImageMock.mockReset()
+ })
+
+ it('loads and displays the current site content in the form', async () => {
+ render()
+ expect(await screen.findByDisplayValue('Vos cheveux, votre couronne')).toBeInTheDocument()
+ expect(screen.getByDisplayValue('hello@braidsbymira.com')).toBeInTheDocument()
+ expect(fromMock).toHaveBeenCalledWith('site_content')
+ })
+
+ it('saves edited content', async () => {
+ render()
+ const headlineInput = await screen.findByDisplayValue('Vos cheveux, votre couronne')
+ await userEvent.clear(headlineInput)
+ await userEvent.type(headlineInput, 'Nouveau titre')
+ await userEvent.click(screen.getByRole('button', { name: /enregistrer/i }))
+
+ await waitFor(() =>
+ expect(updateMock).toHaveBeenCalledWith(
+ expect.objectContaining({ hero_headline: 'Nouveau titre' }),
+ ),
+ )
+ expect(eqUpdateMock).toHaveBeenCalledWith('id', 'default')
+ })
+})
diff --git a/src/pages/admin/AdminContent.tsx b/src/pages/admin/AdminContent.tsx
new file mode 100644
index 0000000..065580f
--- /dev/null
+++ b/src/pages/admin/AdminContent.tsx
@@ -0,0 +1,146 @@
+import { useEffect, useState, type FormEvent } from 'react'
+import { supabase } from '../../lib/supabase'
+import { uploadSalonImage } from '../../lib/storage'
+import styles from './AdminContent.module.css'
+
+interface SiteContentForm {
+ id: string
+ hero_headline: string
+ hero_tagline: string
+ about_bio: string
+ about_portrait_image: string
+ contact_email: string
+ contact_hours: string
+}
+
+function AdminContent() {
+ const [form, setForm] = useState(null)
+ const [file, setFile] = useState(null)
+ const [error, setError] = useState('')
+ const [saving, setSaving] = useState(false)
+
+ useEffect(() => {
+ async function load() {
+ const { data, error: fetchError } = await supabase.from('site_content').select('*').eq('id', 'default').single()
+ if (fetchError) {
+ setError(fetchError.message)
+ return
+ }
+ setForm(data as SiteContentForm)
+ }
+ void load()
+ }, [])
+
+ async function handleSubmit(event: FormEvent) {
+ event.preventDefault()
+ if (!form) return
+ setSaving(true)
+ setError('')
+
+ let about_portrait_image = form.about_portrait_image
+ if (file) {
+ try {
+ about_portrait_image = await uploadSalonImage(file, `about/${file.name}`)
+ } catch {
+ setError("Échec du téléversement de l'image.")
+ setSaving(false)
+ return
+ }
+ }
+
+ const { error: updateError } = await supabase
+ .from('site_content')
+ .update({
+ hero_headline: form.hero_headline,
+ hero_tagline: form.hero_tagline,
+ about_bio: form.about_bio,
+ about_portrait_image,
+ contact_email: form.contact_email,
+ contact_hours: form.contact_hours,
+ })
+ .eq('id', 'default')
+ setSaving(false)
+ if (updateError) {
+ setError(updateError.message)
+ return
+ }
+ setForm({ ...form, about_portrait_image })
+ setFile(null)
+ }
+
+ if (!form) {
+ return (
+
+
Contenu
+ {error &&
{error}
}
+
+ )
+ }
+
+ return (
+
+ )
+}
+
+export default AdminContent