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 ( +
+

Contenu

+
+ + +