diff --git a/src/admin/src/api/banners.ts b/src/admin/src/api/banners.ts new file mode 100644 index 0000000..d5ecd8f --- /dev/null +++ b/src/admin/src/api/banners.ts @@ -0,0 +1,43 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { get, post, put, del } from './client'; +import type { PagedResponse, BannerResponse, CreateOrUpdateBannerRequest } from './types'; + +export function useBanners(page: number, size: number) { + const params = new URLSearchParams({ page: String(page), size: String(size) }); + return useQuery({ + queryKey: ['banners', page, size], + queryFn: () => get>(`/banners?${params}`), + }); +} + +export function useBanner(id: string) { + return useQuery({ + queryKey: ['banners', id], + queryFn: () => get(`/banners/${id}`), + enabled: !!id, + }); +} + +export function useCreateBanner() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateOrUpdateBannerRequest) => post('/banners', data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['banners'] }), + }); +} + +export function useUpdateBanner(id: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateOrUpdateBannerRequest) => put(`/banners/${id}`, data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['banners'] }), + }); +} + +export function useDeleteBanner() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => del(`/banners/${id}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['banners'] }), + }); +} diff --git a/src/admin/src/api/types.ts b/src/admin/src/api/types.ts index fff4959..053a1dc 100644 --- a/src/admin/src/api/types.ts +++ b/src/admin/src/api/types.ts @@ -100,6 +100,47 @@ export interface CreateOrUpdatePartnerRequest { website: string; } +// Banners +export type BannerVariant = 'Announcement' | 'Advertisement' | 'Promo' | 'Event' | 'Maintenance'; + +export interface BannerResponse { + id: string; + titleEn: string | null; + titleFr: string | null; + subtitleEn: string | null; + subtitleFr: string | null; + messageEn: string; + messageFr: string; + variant: BannerVariant; + startDate: string; + endDate: string; + link: string | null; + linkLabelEn: string | null; + linkLabelFr: string | null; + dismissible: boolean; + priority: number; + isEnabled: boolean; + createdAt: string; +} + +export interface CreateOrUpdateBannerRequest { + titleEn: string | null; + titleFr: string | null; + subtitleEn: string | null; + subtitleFr: string | null; + messageEn: string; + messageFr: string; + variant: BannerVariant; + startDate: string; + endDate: string; + link: string | null; + linkLabelEn: string | null; + linkLabelFr: string | null; + dismissible: boolean; + priority: number; + isEnabled: boolean; +} + // External Apps export interface AppSummary { id: string; diff --git a/src/admin/src/components/AdminSidebar.tsx b/src/admin/src/components/AdminSidebar.tsx index 861f097..e31bf5f 100644 --- a/src/admin/src/components/AdminSidebar.tsx +++ b/src/admin/src/components/AdminSidebar.tsx @@ -4,12 +4,14 @@ import { Calendar, Handshake, KeyRound, + Megaphone, } from 'lucide-react'; const navItems = [ { to: '/', label: 'Dashboard', icon: LayoutDashboard, exact: true }, { to: '/events', label: 'Events', icon: Calendar, exact: false }, { to: '/partners', label: 'Partners', icon: Handshake, exact: false }, + { to: '/banners', label: 'Banners', icon: Megaphone, exact: false }, { to: '/apps', label: 'Applications', icon: KeyRound, exact: false }, ] as const; diff --git a/src/admin/src/components/BannerForm.tsx b/src/admin/src/components/BannerForm.tsx new file mode 100644 index 0000000..665c9af --- /dev/null +++ b/src/admin/src/components/BannerForm.tsx @@ -0,0 +1,330 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import { useBanner, useCreateBanner, useUpdateBanner } from '../api/banners'; +import type { CreateOrUpdateBannerRequest, BannerVariant } from '../api/types'; +import { ArrowLeft, Save, Loader2 } from 'lucide-react'; + +interface BannerFormProps { + bannerId?: string; +} + +const variants: BannerVariant[] = ['Announcement', 'Advertisement', 'Promo', 'Event', 'Maintenance']; + +function toLocalInput(iso: string): string { + const d = new Date(iso); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +export function BannerForm({ bannerId }: BannerFormProps) { + const navigate = useNavigate(); + const isEdit = !!bannerId; + + const { data: existing, isLoading: loadingExisting } = useBanner(bannerId ?? ''); + const createBanner = useCreateBanner(); + const updateBanner = useUpdateBanner(bannerId ?? ''); + + const [titleEn, setTitleEn] = useState(''); + const [titleFr, setTitleFr] = useState(''); + const [subtitleEn, setSubtitleEn] = useState(''); + const [subtitleFr, setSubtitleFr] = useState(''); + const [messageEn, setMessageEn] = useState(''); + const [messageFr, setMessageFr] = useState(''); + const [variant, setVariant] = useState('Announcement'); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + const [link, setLink] = useState(''); + const [linkLabelEn, setLinkLabelEn] = useState(''); + const [linkLabelFr, setLinkLabelFr] = useState(''); + const [dismissible, setDismissible] = useState(true); + const [priority, setPriority] = useState(0); + const [isEnabled, setIsEnabled] = useState(true); + + useEffect(() => { + if (!existing) return; + setTitleEn(existing.titleEn ?? ''); + setTitleFr(existing.titleFr ?? ''); + setSubtitleEn(existing.subtitleEn ?? ''); + setSubtitleFr(existing.subtitleFr ?? ''); + setMessageEn(existing.messageEn); + setMessageFr(existing.messageFr); + setVariant(existing.variant); + setStartDate(toLocalInput(existing.startDate)); + setEndDate(toLocalInput(existing.endDate)); + setLink(existing.link ?? ''); + setLinkLabelEn(existing.linkLabelEn ?? ''); + setLinkLabelFr(existing.linkLabelFr ?? ''); + setDismissible(existing.dismissible); + setPriority(existing.priority); + setIsEnabled(existing.isEnabled); + }, [existing]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const data: CreateOrUpdateBannerRequest = { + titleEn: titleEn.trim() || null, + titleFr: titleFr.trim() || null, + subtitleEn: subtitleEn.trim() || null, + subtitleFr: subtitleFr.trim() || null, + messageEn, + messageFr, + variant, + startDate: new Date(startDate).toISOString(), + endDate: new Date(endDate).toISOString(), + link: link.trim() || null, + linkLabelEn: linkLabelEn.trim() || null, + linkLabelFr: linkLabelFr.trim() || null, + dismissible, + priority, + isEnabled, + }; + + const onSuccess = () => navigate({ to: '/banners' }); + if (isEdit) { + updateBanner.mutate(data, { onSuccess }); + } else { + createBanner.mutate(data, { onSuccess }); + } + }; + + if (isEdit && loadingExisting) { + return ( +
+ +
+ ); + } + + const saving = createBanner.isPending || updateBanner.isPending; + + return ( +
+
+ +
+

+ {isEdit ? 'Edit Banner' : 'New Banner'} +

+

+ Scheduled site-wide announcements +

+
+
+ +
+
+
+

Headline (optional)

+

+ If set, the banner uses the rich layout (title + subtitle + message + CTA). + Leave empty for a compact single-line banner. +

+
+
+
+ + setTitleEn(e.target.value)} + maxLength={120} + className="input" + placeholder="AI Skills Fest" + /> +
+
+ + setTitleFr(e.target.value)} + maxLength={120} + className="input" + placeholder="Festival des compétences IA" + /> +
+
+ + setSubtitleEn(e.target.value)} + maxLength={80} + className="input" + placeholder="June 8-12, 2026" + /> +
+
+ + setSubtitleFr(e.target.value)} + maxLength={80} + className="input" + placeholder="8-12 juin 2026" + /> +
+
+
+ +
+

Message

+
+
+ +