diff --git a/client/web/package-lock.json b/client/web/package-lock.json index 407533e8..f79add7f 100644 --- a/client/web/package-lock.json +++ b/client/web/package-lock.json @@ -112,6 +112,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -3459,6 +3460,7 @@ "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3469,6 +3471,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3479,6 +3482,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3528,6 +3532,7 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -3671,19 +3676,6 @@ "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@typescript-eslint/utils": { "version": "8.56.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz", @@ -3766,6 +3758,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3859,9 +3852,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3914,6 +3907,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4278,7 +4272,8 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -4385,6 +4380,7 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4753,9 +4749,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -5639,9 +5635,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -5743,6 +5739,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5773,6 +5770,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5785,6 +5783,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -6203,6 +6202,7 @@ "resolved": "https://registry.npmjs.org/supertokens-web-js/-/supertokens-web-js-0.16.0.tgz", "integrity": "sha512-wuIdlVJtOsx4ZX0kCyl8lxmmAodXLlMAniZEHyVhsH2fhersh7bMrHpvgN9WoC470HYNC22qpHdlJngvyh/cSA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@simplewebauthn/browser": "^13.0.0", "supertokens-js-override": "0.0.4", @@ -6314,10 +6314,11 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6386,6 +6387,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6572,6 +6574,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -6659,10 +6662,11 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6728,6 +6732,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/client/web/src/pages/admin/_shared/AppSidebar.tsx b/client/web/src/pages/admin/_shared/AppSidebar.tsx index e810acf6..6f31e781 100644 --- a/client/web/src/pages/admin/_shared/AppSidebar.tsx +++ b/client/web/src/pages/admin/_shared/AppSidebar.tsx @@ -3,6 +3,7 @@ import { Calendar, ClipboardList, + Handshake, ScanLine, Settings, Star, @@ -51,6 +52,11 @@ const eventNav = [ url: "/admin/schedule", icon: Calendar, }, + { + name: "Sponsors", + url: "/admin/sponsors", + icon: Handshake, + }, ]; const superAdminNav = [ diff --git a/client/web/src/pages/admin/index.ts b/client/web/src/pages/admin/index.ts index 495e36e5..e8c307c6 100644 --- a/client/web/src/pages/admin/index.ts +++ b/client/web/src/pages/admin/index.ts @@ -2,3 +2,4 @@ export { default as AllApplicantsPage } from "./all-applicants/AllApplicantsPage export { default as ReviewsPage } from "./reviews/ReviewsPage"; export { default as ScansPage } from "./scans/ScansPage"; export { default as SchedulePage } from "./schedule/SchedulePage"; +export { default as SponsorsPage } from "./sponsors/SponsorsPage"; diff --git a/client/web/src/pages/admin/sponsors/SponsorsPage.tsx b/client/web/src/pages/admin/sponsors/SponsorsPage.tsx new file mode 100644 index 00000000..1c0e88f0 --- /dev/null +++ b/client/web/src/pages/admin/sponsors/SponsorsPage.tsx @@ -0,0 +1,56 @@ +import { useEffect } from "react"; + +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; + +import { SponsorsTable } from "./components/SponsorsTable"; +import { useSponsorsStore } from "./store"; + +export default function SponsorsPage() { + const { + sponsors, + loading, + saving, + fetch: fetchSponsors, + createSponsor, + updateSponsor, + deleteSponsor, + uploadLogo, + } = useSponsorsStore(); + + useEffect(() => { + const controller = new AbortController(); + fetchSponsors(controller.signal); + return () => controller.abort(); + }, [fetchSponsors]); + + if (loading && sponsors.length === 0) { + return ( +
+ + + + + + {[...Array(3)].map((_, i) => ( + + ))} + + +
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/client/web/src/pages/admin/sponsors/api.ts b/client/web/src/pages/admin/sponsors/api.ts new file mode 100644 index 00000000..bc759691 --- /dev/null +++ b/client/web/src/pages/admin/sponsors/api.ts @@ -0,0 +1,56 @@ +import { + deleteRequest, + getRequest, + postRequest, + putRequest, +} from "@/shared/lib/api"; +import type { ApiResponse } from "@/types"; + +import type { Sponsor, SponsorListResponse, SponsorPayload } from "./types"; + +export async function fetchSponsors( + signal?: AbortSignal, +): Promise> { + return getRequest("/admin/sponsors", "sponsors", signal); +} + +export async function createSponsor( + payload: SponsorPayload, + signal?: AbortSignal, +): Promise> { + return postRequest("/admin/sponsors", payload, "sponsor", signal); +} + +export async function updateSponsor( + id: string, + payload: SponsorPayload, + signal?: AbortSignal, +): Promise> { + return putRequest( + `/admin/sponsors/${id}`, + payload, + "sponsor", + signal, + ); +} + +export async function deleteSponsor( + id: string, + signal?: AbortSignal, +): Promise> { + return deleteRequest(`/admin/sponsors/${id}`, "sponsor", signal); +} + +export async function uploadSponsorLogo( + sponsorId: string, + logoData: string, + contentType: string, + signal?: AbortSignal, +): Promise> { + return putRequest( + `/admin/sponsors/${sponsorId}/logo`, + { logo_data: logoData, content_type: contentType }, + "sponsor logo", + signal, + ); +} diff --git a/client/web/src/pages/admin/sponsors/components/SponsorFormDialog.tsx b/client/web/src/pages/admin/sponsors/components/SponsorFormDialog.tsx new file mode 100644 index 00000000..1a98aac4 --- /dev/null +++ b/client/web/src/pages/admin/sponsors/components/SponsorFormDialog.tsx @@ -0,0 +1,268 @@ +import { ImagePlus, Loader2, X } from "lucide-react"; +import { useRef, useState } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; + +import type { Sponsor, SponsorPayload } from "../types"; + +const TIER_OPTIONS = [ + "Title", + "Platinum", + "Gold", + "Silver", + "Bronze", + "Standard", +]; +const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"]; +const MAX_SIZE_BYTES = 1 * 1024 * 1024; // 1MB + +interface SponsorFormDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + sponsor: Sponsor | null; + saving: boolean; + onSubmit: (payload: SponsorPayload, logoFile?: File) => void; +} + +function SponsorForm({ + sponsor, + saving, + onSubmit, + onCancel, +}: { + sponsor: Sponsor | null; + saving: boolean; + onSubmit: (payload: SponsorPayload, logoFile?: File) => void; + onCancel: () => void; +}) { + const [name, setName] = useState(sponsor?.name ?? ""); + const [tier, setTier] = useState(sponsor?.tier ?? "Standard"); + const [websiteUrl, setWebsiteUrl] = useState(sponsor?.website_url ?? ""); + const [description, setDescription] = useState(sponsor?.description ?? ""); + const [displayOrder, setDisplayOrder] = useState(sponsor?.display_order ?? 0); + const [logoFile, setLogoFile] = useState(null); + const [logoPreview, setLogoPreview] = useState( + sponsor?.logo_data + ? `data:${sponsor.logo_content_type};base64,${sponsor.logo_data}` + : "", + ); + const logoInputRef = useRef(null); + + const handleLogoChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (!ALLOWED_TYPES.includes(file.type)) { + toast.error("Unsupported file type. Use PNG, JPEG, WebP, or GIF."); + return; + } + + if (file.size > MAX_SIZE_BYTES) { + toast.error("File too large. Maximum size is 1MB."); + return; + } + + setLogoFile(file); + const reader = new FileReader(); + reader.onload = () => setLogoPreview(reader.result as string); + reader.readAsDataURL(file); + }; + + const clearLogo = () => { + setLogoFile(null); + setLogoPreview( + sponsor?.logo_data + ? `data:${sponsor.logo_content_type};base64,${sponsor.logo_data}` + : "", + ); + if (logoInputRef.current) logoInputRef.current.value = ""; + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim() || !tier) return; + + onSubmit( + { + name: name.trim(), + tier, + website_url: websiteUrl.trim(), + description: description.trim(), + display_order: displayOrder, + }, + logoFile ?? undefined, + ); + }; + + return ( +
+
+ +
+ {logoPreview ? ( + Logo preview + ) : ( +
+ +
+ )} +
+ + + {logoFile && ( + + )} +
+
+

+ PNG, JPEG, WebP, or GIF (max 1MB) +

+
+
+ + setName(e.target.value)} + placeholder="Sponsor name" + required + /> +
+
+ + +
+
+ + setWebsiteUrl(e.target.value)} + placeholder="https://example.com" + type="url" + /> +
+
+ +