From bad9f1a76fb96ab83aaea8a7b75bf4b6e6e01b54 Mon Sep 17 00:00:00 2001 From: athulrajtflycatchtech Date: Thu, 20 Aug 2026 16:03:39 +0530 Subject: [PATCH] feat(admin-pane: client-logo, client-testimonial): enhance Administration panel with new components and features for case studies and client testimonials; add CRUD operations, state management, and UI updates for improved content management --- .../scripts/check-contract-drift.mjs | 2 + apps/Administration-FE/scripts/check-i18n.mjs | 4 + .../scripts/generate-client.mjs | 13 + .../src/components/AdminShell.tsx | 102 ++++++ .../src/components/ClientLogoForm.tsx | 188 ++++++++++ .../src/components/ClientLogosList.tsx | 196 +++++++++++ .../src/components/ClientTestimonialForm.tsx | 290 ++++++++++++++++ .../src/components/ClientTestimonialsList.tsx | 203 +++++++++++ apps/Administration-FE/src/i18n/en.json | 69 ++++ apps/Administration-FE/src/lib/admin-api.ts | 72 ++++ .../Administration-FE/src/lib/admin-routes.ts | 8 + .../src/pages/admin/client-logos.astro | 4 + .../src/pages/admin/client-testimonials.astro | 4 + apps/Administration-FE/src/styles/admin.css | 17 + .../tests/unit/admin-routes.test.ts | 4 + .../alembic/versions/010_client_logos.py | 36 ++ .../versions/011_client_testimonials.py | 45 +++ .../flycatch_api/api/admin_client_logos.py | 69 ++++ .../api/admin_client_testimonials.py | 77 ++++ .../flycatch_api/api/public_client_logos.py | 14 + .../api/public_client_testimonials.py | 14 + apps/Backend/src/flycatch_api/main.py | 8 + .../src/flycatch_api/models/__init__.py | 4 + .../src/flycatch_api/models/client_logo.py | 26 ++ .../flycatch_api/models/client_testimonial.py | 36 ++ .../schemas/admin_client_logos.py | 45 +++ .../schemas/admin_client_testimonials.py | 63 ++++ .../schemas/public_client_logos.py | 15 + .../schemas/public_client_testimonials.py | 24 ++ .../services/client_logo_service.py | 140 ++++++++ .../services/client_testimonial_service.py | 194 +++++++++++ .../integration/test_admin_client_logos.py | 93 +++++ .../test_admin_client_testimonials.py | 114 ++++++ .../integration/test_public_client_logos.py | 42 +++ .../test_public_client_testimonials.py | 60 ++++ .../contracts/admin-client-logos.v1.yaml | 262 ++++++++++++++ .../contracts/public-client-logos.v1.yaml | 49 +++ specs/006-admin-client-logos/data-model.md | 26 ++ specs/006-admin-client-logos/quickstart.md | 15 + specs/006-admin-client-logos/spec.md | 74 ++++ .../admin-client-testimonials.v1.yaml | 328 ++++++++++++++++++ .../public-client-testimonials.v1.yaml | 81 +++++ .../data-model.md | 35 ++ .../quickstart.md | 15 + specs/007-admin-client-testimonials/spec.md | 75 ++++ 45 files changed, 3255 insertions(+) create mode 100644 apps/Administration-FE/src/components/ClientLogoForm.tsx create mode 100644 apps/Administration-FE/src/components/ClientLogosList.tsx create mode 100644 apps/Administration-FE/src/components/ClientTestimonialForm.tsx create mode 100644 apps/Administration-FE/src/components/ClientTestimonialsList.tsx create mode 100644 apps/Administration-FE/src/pages/admin/client-logos.astro create mode 100644 apps/Administration-FE/src/pages/admin/client-testimonials.astro create mode 100644 apps/Backend/alembic/versions/010_client_logos.py create mode 100644 apps/Backend/alembic/versions/011_client_testimonials.py create mode 100644 apps/Backend/src/flycatch_api/api/admin_client_logos.py create mode 100644 apps/Backend/src/flycatch_api/api/admin_client_testimonials.py create mode 100644 apps/Backend/src/flycatch_api/api/public_client_logos.py create mode 100644 apps/Backend/src/flycatch_api/api/public_client_testimonials.py create mode 100644 apps/Backend/src/flycatch_api/models/client_logo.py create mode 100644 apps/Backend/src/flycatch_api/models/client_testimonial.py create mode 100644 apps/Backend/src/flycatch_api/schemas/admin_client_logos.py create mode 100644 apps/Backend/src/flycatch_api/schemas/admin_client_testimonials.py create mode 100644 apps/Backend/src/flycatch_api/schemas/public_client_logos.py create mode 100644 apps/Backend/src/flycatch_api/schemas/public_client_testimonials.py create mode 100644 apps/Backend/src/flycatch_api/services/client_logo_service.py create mode 100644 apps/Backend/src/flycatch_api/services/client_testimonial_service.py create mode 100644 apps/Backend/tests/integration/test_admin_client_logos.py create mode 100644 apps/Backend/tests/integration/test_admin_client_testimonials.py create mode 100644 apps/Backend/tests/integration/test_public_client_logos.py create mode 100644 apps/Backend/tests/integration/test_public_client_testimonials.py create mode 100644 specs/006-admin-client-logos/contracts/admin-client-logos.v1.yaml create mode 100644 specs/006-admin-client-logos/contracts/public-client-logos.v1.yaml create mode 100644 specs/006-admin-client-logos/data-model.md create mode 100644 specs/006-admin-client-logos/quickstart.md create mode 100644 specs/006-admin-client-logos/spec.md create mode 100644 specs/007-admin-client-testimonials/contracts/admin-client-testimonials.v1.yaml create mode 100644 specs/007-admin-client-testimonials/contracts/public-client-testimonials.v1.yaml create mode 100644 specs/007-admin-client-testimonials/data-model.md create mode 100644 specs/007-admin-client-testimonials/quickstart.md create mode 100644 specs/007-admin-client-testimonials/spec.md diff --git a/apps/Administration-FE/scripts/check-contract-drift.mjs b/apps/Administration-FE/scripts/check-contract-drift.mjs index 144974d..13e6f8f 100644 --- a/apps/Administration-FE/scripts/check-contract-drift.mjs +++ b/apps/Administration-FE/scripts/check-contract-drift.mjs @@ -13,6 +13,8 @@ const required = [ 'publish.v2.ts', 'admin-blogs.v1.ts', 'admin-case-studies.v1.ts', + 'admin-client-logos.v1.ts', + 'admin-client-testimonials.v1.ts', ]; let failed = false; diff --git a/apps/Administration-FE/scripts/check-i18n.mjs b/apps/Administration-FE/scripts/check-i18n.mjs index 51c4cc6..2b0084a 100644 --- a/apps/Administration-FE/scripts/check-i18n.mjs +++ b/apps/Administration-FE/scripts/check-i18n.mjs @@ -18,6 +18,10 @@ const files = [ 'components/MediaPreview.tsx', 'components/CategoriesList.tsx', 'components/CategoryForm.tsx', + 'components/ClientLogosList.tsx', + 'components/ClientLogoForm.tsx', + 'components/ClientTestimonialsList.tsx', + 'components/ClientTestimonialForm.tsx', 'components/CaseStudiesList.tsx', 'components/CaseStudyForm.tsx', 'components/IndustriesList.tsx', diff --git a/apps/Administration-FE/scripts/generate-client.mjs b/apps/Administration-FE/scripts/generate-client.mjs index cfe306a..0c615cf 100644 --- a/apps/Administration-FE/scripts/generate-client.mjs +++ b/apps/Administration-FE/scripts/generate-client.mjs @@ -11,6 +11,11 @@ const authContractsDir = process.env.CONTRACTS_DIR || join(repoRoot, 'specs/002-auth-rbac/contracts'); const blogsContractsDir = join(repoRoot, 'specs/004-admin-blogs/contracts'); const caseStudiesContractsDir = join(repoRoot, 'specs/005-admin-case-studies/contracts'); +const clientLogosContractsDir = join(repoRoot, 'specs/006-admin-client-logos/contracts'); +const clientTestimonialsContractsDir = join( + repoRoot, + 'specs/007-admin-client-testimonials/contracts', +); const contractSets = [ { @@ -31,6 +36,14 @@ const contractSets = [ dir: caseStudiesContractsDir, files: ['admin-case-studies.v1.yaml'], }, + { + dir: clientLogosContractsDir, + files: ['admin-client-logos.v1.yaml'], + }, + { + dir: clientTestimonialsContractsDir, + files: ['admin-client-testimonials.v1.yaml'], + }, ]; mkdirSync(outDir, { recursive: true }); diff --git a/apps/Administration-FE/src/components/AdminShell.tsx b/apps/Administration-FE/src/components/AdminShell.tsx index 157d5ae..656bcaf 100644 --- a/apps/Administration-FE/src/components/AdminShell.tsx +++ b/apps/Administration-FE/src/components/AdminShell.tsx @@ -30,6 +30,10 @@ import BlogsList from './BlogsList'; import BlogForm from './BlogForm'; import CategoriesList from './CategoriesList'; import CategoryForm from './CategoryForm'; +import ClientLogosList from './ClientLogosList'; +import ClientLogoForm from './ClientLogoForm'; +import ClientTestimonialsList from './ClientTestimonialsList'; +import ClientTestimonialForm from './ClientTestimonialForm'; import CaseStudiesList from './CaseStudiesList'; import CaseStudyForm from './CaseStudyForm'; import IndustriesList from './IndustriesList'; @@ -59,6 +63,8 @@ function applyRoute( setEditingTechnologyId: (id: string | null) => void; setEditingAuthorId: (id: string | null) => void; setEditingCategoryId: (id: string | null) => void; + setEditingClientLogoId: (id: string | null) => void; + setEditingClientTestimonialId: (id: string | null) => void; }, ) { setters.setView(route.view); @@ -72,6 +78,10 @@ function applyRoute( setters.setEditingTechnologyId(route.view === 'technology_form' ? route.editingId : null); setters.setEditingAuthorId(route.view === 'author_form' ? route.editingId : null); setters.setEditingCategoryId(route.view === 'category_form' ? route.editingId : null); + setters.setEditingClientLogoId(route.view === 'client_logo_form' ? route.editingId : null); + setters.setEditingClientTestimonialId( + route.view === 'client_testimonial_form' ? route.editingId : null, + ); } export default function AdminShell() { @@ -112,6 +122,16 @@ export default function AdminShell() { const route = readAdminLocation(); return route.view === 'category_form' ? route.editingId : null; }); + const [editingClientLogoId, setEditingClientLogoId] = useState(() => { + const route = readAdminLocation(); + return route.view === 'client_logo_form' ? route.editingId : null; + }); + const [editingClientTestimonialId, setEditingClientTestimonialId] = useState( + () => { + const route = readAdminLocation(); + return route.view === 'client_testimonial_form' ? route.editingId : null; + }, + ); const [session, setSession] = useState(null); const [siteSettings, setSiteSettings] = useState | null>(null); const [homePage, setHomePage] = useState | null>(null); @@ -132,6 +152,8 @@ export default function AdminShell() { setEditingTechnologyId, setEditingAuthorId, setEditingCategoryId, + setEditingClientLogoId, + setEditingClientTestimonialId, }); }, []); @@ -228,6 +250,14 @@ export default function AdminShell() { document.title = t('admin.workspace.categories'); return; } + if (view === 'client_logos' || view === 'client_logo_form') { + document.title = t('admin.workspace.client_logos'); + return; + } + if (view === 'client_testimonials' || view === 'client_testimonial_form') { + document.title = t('admin.workspace.client_testimonials'); + return; + } if (view === 'roles') { document.title = t('admin.workspace.settings'); return; @@ -327,6 +357,14 @@ export default function AdminShell() { openList('categories'); } + function openClientLogos() { + openList('client_logos'); + } + + function openClientTestimonials() { + openList('client_testimonials'); + } + function onNavClick(event: MouseEvent, href: string) { if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) { return; @@ -496,6 +534,36 @@ export default function AdminShell() { {t('admin.workspace.categories')} +
  • + onNavClick(event, adminListHref('client_logos'))} + > + {t('admin.workspace.client_logos')} + +
  • +
  • + onNavClick(event, adminListHref('client_testimonials'))} + > + {t('admin.workspace.client_testimonials')} + +
  • {canManageRoles && (
  • )} + {view === 'client_logos' && ( + openForm('client_logos', null)} + onEdit={(id) => openForm('client_logos', id)} + /> + )} + {view === 'client_logo_form' && ( + { + setMessage(t('admin.client_logos.saved')); + openClientLogos(); + }} + /> + )} + {view === 'client_testimonials' && ( + openForm('client_testimonials', null)} + onEdit={(id) => openForm('client_testimonials', id)} + /> + )} + {view === 'client_testimonial_form' && ( + { + setMessage(t('admin.client_testimonials.saved')); + openClientTestimonials(); + }} + /> + )} {view === 'roles' && ( void; + onSaved: () => void; +} + +type Status = 'draft' | 'publish'; + +export default function ClientLogoForm({ logoId, onCancel, onSaved }: Props) { + const [altText, setAltText] = useState(''); + const [colourKey, setColourKey] = useState(null); + const [whiteKey, setWhiteKey] = useState(null); + const [colourFile, setColourFile] = useState(null); + const [whiteFile, setWhiteFile] = useState(null); + const [status, setStatus] = useState('draft'); + const [error, setError] = useState(null); + const [fieldError, setFieldError] = useState(null); + const [ready, setReady] = useState(!logoId); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!logoId) { + setAltText(''); + setColourKey(null); + setWhiteKey(null); + setColourFile(null); + setWhiteFile(null); + setStatus('draft'); + setReady(true); + return; + } + getClientLogo(logoId) + .then((logo) => { + setAltText(logo.alt_text); + setColourKey(logo.colour_logo_key); + setWhiteKey(logo.white_logo_key); + setStatus(logo.status); + setReady(true); + }) + .catch(() => { + setError(t('admin.workspace.request_failed')); + setReady(true); + }); + }, [logoId]); + + async function persist(nextStatus: Status) { + setError(null); + setFieldError(null); + if (!altText.trim()) { + setFieldError(t('admin.field.required')); + return; + } + setSaving(true); + try { + let nextColour = colourKey; + let nextWhite = whiteKey; + if (colourFile) { + nextColour = (await uploadMedia(colourFile)).key; + } + if (whiteFile) { + nextWhite = (await uploadMedia(whiteFile)).key; + } + const payload: ClientLogoWrite = { + alt_text: altText.trim(), + colour_logo_key: nextColour, + white_logo_key: nextWhite, + status: nextStatus, + }; + if (logoId) await updateClientLogo(logoId, payload); + else await createClientLogo(payload); + onSaved(); + } catch (caught) { + setError(apiErrorMessage(caught)); + } finally { + setSaving(false); + } + } + + async function save(event: FormEvent) { + event.preventDefault(); + await persist(status); + } + + if (!ready) { + return ( +
    +

    +

    +
    + ); + } + + return ( +
    +
    +

    {logoId ? t('admin.client_logos.edit') : t('admin.client_logos.add')}

    +
    +
    + + + + + + {fieldError && ( +

    + {fieldError} +

    + )} + {error && ( +

    + {error} +

    + )} +
    + + + {status === 'publish' ? ( + + ) : ( + + )} +
    + +
    + ); +} diff --git a/apps/Administration-FE/src/components/ClientLogosList.tsx b/apps/Administration-FE/src/components/ClientLogosList.tsx new file mode 100644 index 0000000..19a6af5 --- /dev/null +++ b/apps/Administration-FE/src/components/ClientLogosList.tsx @@ -0,0 +1,196 @@ +import { useEffect, useRef, useState, type FormEvent } from 'react'; +import { + apiErrorMessage, + deleteClientLogo, + listClientLogos, + type ClientLogoList, +} from '../lib/admin-api'; +import { t } from '../lib/i18n'; +import TableLogo from './TableLogo'; + +interface Props { + onAdd: () => void; + onEdit: (id: string) => void; + notice: string | null; +} + +export default function ClientLogosList({ onAdd, onEdit, notice }: Props) { + const [query, setQuery] = useState(''); + const [appliedQuery, setAppliedQuery] = useState(''); + const [page, setPage] = useState(1); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [pendingId, setPendingId] = useState(null); + const dialogRef = useRef(null); + + async function load(nextQuery: string, nextPage: number) { + setError(null); + try { + const result = await listClientLogos(nextQuery, nextPage); + setData(result); + } catch { + setError(t('admin.workspace.request_failed')); + } + } + + useEffect(() => { + load(appliedQuery, page).catch(() => undefined); + }, [appliedQuery, page]); + + function search(event: FormEvent) { + event.preventDefault(); + setPage(1); + setAppliedQuery(query.trim()); + } + + function openDelete(id: string) { + setPendingId(id); + dialogRef.current?.showModal(); + } + + function closeDelete() { + setPendingId(null); + dialogRef.current?.close(); + } + + async function confirmDelete() { + if (!pendingId) return; + try { + await deleteClientLogo(pendingId); + closeDelete(); + await load(appliedQuery, page); + } catch (caught) { + closeDelete(); + setError(apiErrorMessage(caught)); + } + } + + const totalPages = data ? Math.max(1, Math.ceil(data.total / data.per_page)) : 1; + const loading = data === null && !error; + + return ( +
    +
    +

    {t('admin.client_logos.title')}

    + +
    + {notice && ( +

    + {notice} +

    + )} +
    + + +
    + {error && ( +

    + {error} +

    + )} +
    + + + + + + + + + + + + + {data?.items.map((item, index) => ( + + + + + + + + + ))} + +
    {t('admin.client_logos.id')}{t('admin.client_logos.colour_logo')}{t('admin.client_logos.white_logo')}{t('admin.client_logos.alt_text')}{t('admin.client_logos.state')}{t('admin.client_logos.actions')}
    + {(data.page - 1) * data.per_page + index + 1} + + + + + {item.alt_text} + {item.state === 'publish' + ? t('admin.client_logos.status.publish') + : t('admin.client_logos.status.draft')} + + + +
    + {loading && ( +

    +

    + )} + {data && data.items.length === 0 && ( +

    {t('admin.client_logos.empty')}

    + )} +
    + {data && data.total > data.per_page && ( + + )} + setPendingId(null)}> +
    { + event.preventDefault(); + confirmDelete().catch(() => undefined); + }} + > +

    {t('admin.client_logos.delete.confirm')}

    +

    {t('admin.client_logos.delete.confirm_body')}

    +
    + + +
    +
    +
    +
    + ); +} diff --git a/apps/Administration-FE/src/components/ClientTestimonialForm.tsx b/apps/Administration-FE/src/components/ClientTestimonialForm.tsx new file mode 100644 index 0000000..87b0bc6 --- /dev/null +++ b/apps/Administration-FE/src/components/ClientTestimonialForm.tsx @@ -0,0 +1,290 @@ +import { useEffect, useState, type FormEvent } from 'react'; +import { + apiErrorMessage, + createClientTestimonial, + getClientTestimonial, + updateClientTestimonial, + uploadMedia, + type ClientTestimonialWrite, +} from '../lib/admin-api'; +import { t } from '../lib/i18n'; +import MediaPreview from './MediaPreview'; + +interface Props { + testimonialId: string | null; + onCancel: () => void; + onSaved: () => void; +} + +type Status = 'draft' | 'publish'; + +export default function ClientTestimonialForm({ testimonialId, onCancel, onSaved }: Props) { + const [clientName, setClientName] = useState(''); + const [title, setTitle] = useState(''); + const [review, setReview] = useState(''); + const [designation, setDesignation] = useState(''); + const [company, setCompany] = useState(''); + const [country, setCountry] = useState(''); + const [imageKey, setImageKey] = useState(null); + const [imageFile, setImageFile] = useState(null); + const [altText, setAltText] = useState(''); + const [isClutch, setIsClutch] = useState(false); + const [order, setOrder] = useState(0); + const [reviewLink, setReviewLink] = useState(''); + const [status, setStatus] = useState('draft'); + const [error, setError] = useState(null); + const [fieldError, setFieldError] = useState(null); + const [ready, setReady] = useState(!testimonialId); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!testimonialId) { + setClientName(''); + setTitle(''); + setReview(''); + setDesignation(''); + setCompany(''); + setCountry(''); + setImageKey(null); + setImageFile(null); + setAltText(''); + setIsClutch(false); + setOrder(0); + setReviewLink(''); + setStatus('draft'); + setReady(true); + return; + } + getClientTestimonial(testimonialId) + .then((item) => { + setClientName(item.client_name); + setTitle(item.title); + setReview(item.review); + setDesignation(item.client_designation); + setCompany(item.client_company); + setCountry(item.country); + setImageKey(item.image_key); + setAltText(item.alt_text); + setIsClutch(item.is_clutch_review); + setOrder(item.order); + setReviewLink(item.review_link); + setStatus(item.status); + setReady(true); + }) + .catch(() => { + setError(t('admin.workspace.request_failed')); + setReady(true); + }); + }, [testimonialId]); + + async function persist(nextStatus: Status) { + setError(null); + setFieldError(null); + if (!clientName.trim() || !title.trim() || !review.trim()) { + setFieldError(t('admin.field.required')); + return; + } + if (!Number.isFinite(order) || order < 0) { + setFieldError(t('admin.client_testimonials.order.invalid')); + return; + } + setSaving(true); + try { + let nextImage = imageKey; + if (imageFile) { + nextImage = (await uploadMedia(imageFile)).key; + } + const payload: ClientTestimonialWrite = { + client_name: clientName.trim(), + title: title.trim(), + review: review.trim(), + client_designation: designation.trim(), + client_company: company.trim(), + country: country.trim(), + image_key: nextImage, + alt_text: altText.trim(), + is_clutch_review: isClutch, + order: Math.trunc(order), + review_link: reviewLink.trim(), + status: nextStatus, + }; + if (testimonialId) await updateClientTestimonial(testimonialId, payload); + else await createClientTestimonial(payload); + onSaved(); + } catch (caught) { + setError(apiErrorMessage(caught)); + } finally { + setSaving(false); + } + } + + async function save(event: FormEvent) { + event.preventDefault(); + await persist(status); + } + + if (!ready) { + return ( +
    +

    +

    +
    + ); + } + + return ( +
    +
    +

    + {testimonialId + ? t('admin.client_testimonials.edit') + : t('admin.client_testimonials.add')} +

    +
    +
    + + +