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:
16
src/pages/admin/AdminContent.module.css
Normal file
16
src/pages/admin/AdminContent.module.css
Normal 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';
|
||||
}
|
||||
67
src/pages/admin/AdminContent.test.tsx
Normal file
67
src/pages/admin/AdminContent.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
146
src/pages/admin/AdminContent.tsx
Normal file
146
src/pages/admin/AdminContent.tsx
Normal 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
|
||||
Reference in New Issue
Block a user