feat: add AdminContent page for editing site_content

Implement new admin page for editing the site_content singleton table. Includes:
- Form with fields for hero headline/tagline, about bio, portrait image, contact email, and hours
- Image upload via uploadSalonImage with about/ path prefix
- Data persistence via supabase.from('site_content').update().eq('id', 'default')
- Composable styles from adminShared.module.css
- Full test coverage: loading content and saving edits

Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH
This commit is contained in:
belviskhoremk
2026-08-13 09:21:06 +02:00
parent 8806ed2f04
commit bdf8d9cc3a
3 changed files with 229 additions and 0 deletions

View File

@@ -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';
}

View File

@@ -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(<AdminContent />)
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(<AdminContent />)
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')
})
})

View File

@@ -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<SiteContentForm | null>(null)
const [file, setFile] = useState<File | null>(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 (
<div className={styles.page}>
<h1>Contenu</h1>
{error && <p className={styles.error}>{error}</p>}
</div>
)
}
return (
<div className={styles.page}>
<h1>Contenu</h1>
<form className={styles.form} onSubmit={handleSubmit}>
<label htmlFor="content-hero-headline">
Titre principal
<input
id="content-hero-headline"
value={form.hero_headline}
onChange={(e) => setForm({ ...form, hero_headline: e.target.value })}
required
/>
</label>
<label htmlFor="content-hero-tagline">
Sous-titre
<input
id="content-hero-tagline"
value={form.hero_tagline}
onChange={(e) => setForm({ ...form, hero_tagline: e.target.value })}
required
/>
</label>
<label htmlFor="content-about-bio">
Bio (À propos de Mira)
<textarea
id="content-about-bio"
value={form.about_bio}
onChange={(e) => setForm({ ...form, about_bio: e.target.value })}
required
/>
</label>
<label htmlFor="content-about-portrait">
Photo de portrait
<input
id="content-about-portrait"
type="file"
accept="image/*"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
</label>
<label htmlFor="content-contact-email">
Courriel de contact
<input
id="content-contact-email"
type="email"
value={form.contact_email}
onChange={(e) => setForm({ ...form, contact_email: e.target.value })}
required
/>
</label>
<label htmlFor="content-contact-hours">
Heures d'ouverture
<input
id="content-contact-hours"
value={form.contact_hours}
onChange={(e) => setForm({ ...form, contact_hours: e.target.value })}
required
/>
</label>
{error && <p className={styles.error}>{error}</p>}
<button type="submit" className={styles.button} disabled={saving}>Enregistrer le contenu</button>
</form>
</div>
)
}
export default AdminContent