diff --git a/.gitignore b/.gitignore index 92cc87d4..842ac2a9 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,8 @@ sitemap*.xml **/robots.txt test-results/.last-run.json test-scroll-behavior.sh +.claude/ +.env + +# Playwright MCP screenshot/console artifacts +.playwright-mcp/ diff --git a/CLAUDE.md b/CLAUDE.md index 2e93b0a7..bd033779 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,28 @@ # OHack Frontend Development Guidelines +## "Refined" design scope (civic-editorial facelift) + +Full spec + rollout checklist: **`docs/refined-design-system.md`** (read this before extending the look to more pages). Migrated so far: `/` (`src/pages/index.js`), `/projects` (`src/components/ProjectList/*`), the global NavBar (incl. a refined mobile layout — centered logo + refined dropdown), `/about`, `/about/judges`, `/about/mentors` (`Mentorship` rewritten as refined sections), `/about/success-stories`, `/about/completion`, `/sponsor`, `/nonprofits` (`NonProfitList` + new calm card `NonProfitListTileRefined`; original `NonProfitListTile` stays for event pages), `/blog` (`BlogPage`; reused `News` list kept), `/onboarding` (refined chrome; wizard logic intact), `/signup` (full rewrite: editorial hero, "What you get" benefit cards, numbered join steps beside the framed `join_slack_1.png`, navy CTA band; primary CTA is now a real `` — dropped the JS `router.push`/`window.open` branch; `handleSignupClick` only fires the `CompleteRegistration` GA event), `/volunteer`, `/about/hackers`, `/about/process` (refined chrome; Mermaid flow + Gantt diagrams kept in calm card frames), `/praise` (refined chrome; `PraiseBoard` feed kept), `/hack/code-of-conduct` (full rewrite: editorial hero, core-values cards, numbered accessibility list, expected-vs-unacceptable two-col w/ +/× markers, navy CTA band), `/profile/[userid]` public (`PublicProfile`), and a full refined pass on the own `/profile` editor (`Profile.js` — editorial masthead, hairline sticky tab strip, scoped navy/terracotta MUI theme, `PanelHeader` on every tab, privacy legend on Basic Info, hairline rules in Volunteer History, `.ohx-card` Giveaway Entries; form logic untouched), `/myfeedback` (full rewrite: editorial hero, navy CircularProgress score ring w/ Fraunces number, hairline skill accordions w/ navy LinearProgress bars, `.ohx-card` entries + `.ohx-tag` chips) and `/feedback/[userid]` (`GiveFeedback`, `ssr:false`: hero + `Section`-framed form, navy-themed MUI Select/Radio/Slider/Checkbox, native `.ohx-btn` submit; also fixed a pre-existing hooks-order bug where `if (!user)` returned before `useEffect`), `/hack/[event_id]/manageteam` (full rewrite: `RefinedRoot`+`RefinedFonts`, editorial masthead, `.ohx-card` gating panels, calm `--surface-2` panels for auth/apply gates; split `error` → `teamsError`+`formError`; lazy Slack/nonprofit fetches gated by `activeStep`/`showNewTeamForm` + `slackFetchedRef`/`nonprofitFetchedRef`; `onTeamUpdated` callback propagated to `TeamStatusPanel` so DevPost/demo label updates without reload; `fetchMyTeams()` after submit + scroll to `#team-hub`; `noindex` meta; no PII logs; `active_days=365` for Slack fetch), and `/hack/[event_id]/team/[team_id]` (full rewrite: editorial masthead + hairline `.ohx-card` sections + a **deep-linkable sticky Table of Contents** — sections are `
` with hover `#` copy-link anchors (keyboard accessible via `&:focus-visible`), the TOC lists only present sections (`aria-current="location"`) and tracks the active one via `IntersectionObserver`; embedded `MentorTeamPanel`/`TeamCompletionChecklist` keep their own styling in anchored `headed={false}` wrappers). **Key invariants on this page:** (1) Status labels come from `TEAM_STATUS_OPTIONS.find()` via `statusLabel()` — never render raw enum strings like `NONPROFIT_SELECTED`; winning statuses get a 🏆 prefix from `getWinningStatus()`; `INACTIVE` skips the status tag since the dot already conveys it. (2) `SectionBlock` is **module-scope** (not inside the component) — defining it inside caused a remount storm on every IntersectionObserver `setActiveId` tick, wiping in-progress mentor note drafts and reloading the demo iframe; it receives `copiedId`/`onCopyLink` as props. (3) `parseLocalDate` from `src/lib/dateUtils.js` is used for all event window checks (`eventHasStarted`, `eventEnded`) so date-only strings aren't parsed as UTC midnight. (4) Membership check uses `useTeamMembership(eventId, teamId)` from `src/hooks/use-team-membership.js` — shared by the page and passed as `isOnTeam`/`membershipChecked` props to `TeamCompletionChecklist` (no duplicate fetch). Page uses result to show: "Manage your team" primary CTA for members, a combined nudge card for missing DevPost/demo, and a ghost "Want to join this team?" affordance for non-members when status is joinable. (5) Nonprofit: never render `selected_nonprofit_id` text — only show when `nonprofitData.name` is available; `getStaticProps` fetches `{ name, description }` into `nonprofitData`; a "Nonprofit partner" section card links to `/nonprofit/`. (6) `awards[]` rendered as `ohx-tag--accent` chips after the status tag. (7) All GitHub repos in `github_links[]` rendered (handles both string and `{link,name}` shapes). (8) `getStaticProps` rethrows network errors (ISR keeps the last good version) and returns `notFound` only on genuine 404. **Harmonized (not full rewrite):** `/hack` index (keeps its bespoke finder + `HackPageNav`; hero/CTAs/Support band recolored via inline `RX` tokens). The upcoming/current event cards were refined too: `HackathonList` full-mode heading → Fraunces eyebrow; `EventFeature` full card rewritten to a refined hairline card (eyebrow date + quiet type tag, Fraunces title, navy donation rings, navy-primary/ghost event-link buttons, "View event →"; fixed the old nested-anchor bug; var-fallback colors since `/hack` isn't a `RefinedRoot`). `ImpactMetrics` (shared with the archive) → warm `--surface-2` tiles with Fraunces navy numbers + "Impact at a glance" overline (was rainbow `color`-prop numbers). `/hack/[event_id]` got a **masthead-led refined pass**: body wrapped in `` (it's now the `
`; all section IDs + `TableOfContents` + `FloatingNavigation` preserved), `HackathonHeader` rebuilt into a scope-independent editorial masthead (inline styles w/ `var(--x, fallback)` so `/agenda` + `/census` also benefit), refined "Build a team" buttons + recap teaser. Phase-2 refined the **event-only** sub-components: `NonprofitList` (calm clickable cards + navy view-toggle), `EventConstraints`, `DonationProgress` (navy rings), `EventCountdown` (navy countdown card replacing the gradient + flat timeline), `TeamList`/`TeamCard` (flat hairline cards, Fraunces team names, refined "Team members" label + navy Join button — `TeamList` is event-page-only despite earlier substring matches in admin/event-teams; **profile avatars load lazily per-card via IntersectionObserver with `rootMargin: "200px"`, tracked by `fetchedTeamProfilesRef` Set** — do NOT restore the eager `fetchTeamMemberProfiles(teams)` call on mount that fired N parallel requests for all 30+ cards at once). **Backend C7 enrichment:** `get_single_hackathon_event` (`services/hackathons_service.py`) now calls `_enrich_teams_users_batch` after converting team DocumentReferences — one `db.get_all` across all members of all teams, deduped, so the frontend receives `users[]` already as `{id, user_id, name, nickname, profile_image}` objects. `HackathonResults.js`/`TeamList.js` handle both the old id-string and new object shapes (backwards-compatible). The list endpoint `get_hackathon_list` is NOT enriched — only the single-event getter., and `HackathonLeaderboard` (flat cards, warm stat tiles w/ terracotta icons + Fraunces navy numbers, squared navy Org button). **Phase 3 (done) finished the event page**: `EventLinks` (6 rainbow app buttons → one calm uniform card set + calm social-proof strip), `InteractiveFAQ` (flat hairline accordions), `HackathonResults` (calm `--surface-2` frame, Fraunces navy stat numbers; gold/silver/bronze winner medals kept), `VolunteerList` (`PersonCard` → flat hairline). The **shared** ones (`InteractiveFAQ`, `HackathonResults`) use **inline CSS-var fallbacks** (`var(--ink,#16181D)`) so they render refined on their non-`RefinedRoot` routes too (verified on `/hack/[event_id]/results` + `OnboardingFAQ`) — no per-route gating. A final chrome pass refined `TableOfContents` (navy active pill, was magenta), `FloatingNavigation` (navy FAB), `VolunteerList` (navy "Learn more" + navy availability chips; in-person stays green), and `MentorAvailability` (navy banner/Slack button). The event page is now fully refined end-to-end. Also refined: `/hack/request` (host-an-event page + `HackathonRequestForm`) and `/office-hours`. **`/hack/request` is student-first** — org-type selectable cards default to **university** (was corporate); the **$5k budget minimum was removed** (slider min `$0`, never blocks submit, reframed as optional funding); the donation-% ask is **corporate-only** (forced to 0 at submit otherwise); phone is optional; rainbow Papers → calm `--surface-2` cards; everything navy/terracotta via a scoped MUI `ThemeProvider` (`createTheme(base, …)`). **`formData` keys are preserved** — `HackathonRequestDetailDialog`, the edit page `/hack/request/[request_id]`, and the backend all depend on them. **Scroll gotcha:** step nav now `scrollIntoView`s a `formTopRef` on the form's Paper (`scrollMarginTop:88`), NOT `window.scrollTo(0,0)` (which overshot to page-top now that the form sits below the hero). `/office-hours` now renders its JSON-LD, uses the idempotent `initFacebookPixel` (was a raw `ReactPixel.init` — CWV rule), and dropped the artificial 1s loading delay + dead ICS code. Also refined: `/nonprofit/[nonprofit_id]` (`NonProfit.js` — editorial masthead, meta row as `.ohx-tag`/`.ohx-link`s, projects list, canonical www fix) and `/project/[project_id]` (`Project.js` — `RefinedRoot` chrome, soft breadcrumb, canonical). Both share `ProblemStatement.js` which was fully restyled (purple gradient hero → calm `--surface-2` band; gradient chips → `.ohx-tag`; gradient metric tiles → Fraunces navy tiles; gradient section headers → hairline `.ohx-card`; gradient CTAs → `.ohx-btn`). **Key invariant:** `ProblemStatement` is ONLY ever inside a `RefinedRoot`, so it may use scoped `.ohx-*` classes directly (no inline `var(--x, fallback)` needed). Pass `headingLevel="h2"` from the nonprofit page (N cards, nonprofit name is the `

`) and `headingLevel="h1"` from the project page (project title is the `

`). The "Want to help?" toggle is hidden when `status === "production"` (live projects don't need volunteers) but stays visible for users already helping so they can toggle off; `maintenance` status keeps the toggle. `styles/nonprofit/styles.js` is now orphaned — leave in place. + +See `docs/refined-design-system.md`. Pattern for new pages: keep `Head`/`getStaticProps`/schema verbatim, add ``, wrap body in ``, rebuild sections with `.ohx-*` classes, one `

`, preserve GA. + +The homepage and projects page were reimagined away from the old busy/multi-gradient look into a calm "civic editorial" system. Shared tokens + utility classes live in `src/components/design/refined.js`: + +- `` — a `styled('main')` scope that defines CSS variables (`--paper`, `--ink`, `--brand` deep navy `#1B3A6B`, `--accent` terracotta `#E2552E`, `--display` Fraunces, `--body` Hanken Grotesk) and utility classNames (`.ohx-wrap`, `.ohx-display`, `.ohx-eyebrow`, `.ohx-lead`, `.ohx-btn(--primary|--ghost)`, `.ohx-link`, `.ohx-card(--hover)`, `.ohx-tag(--accent)`, `.ohx-sponsors` grayscale logos, `.rise` staggered load anim w/ `animationDelay`). Everything is scoped — it does NOT touch the global MUI light theme. +- `` — Google Fonts ``s (Fraunces + Hanken Grotesk, preconnect + `display=swap`); drop into each page's `next/head`. +- `Eyebrow`, `Stat`, `Arrow` — small shared presentational atoms. +- Rule of thumb here: one accent color, hairline rules, generous whitespace, ONE primary CTA per section. Don't reintroduce colored chips/gradients — that's the "too busy" regression we removed. +- `LeadForm` gained a `bare` prop (drops its lavender box + icon + "Stay in the loop" heading) so the homepage newsletter band provides its own calm framing. +- `useHackathonEvents` now returns `loading` (was destructured but never returned). Homepage uses it to reserve event-grid space (CLS). +- Projects page no longer uses `ProjectList/filters/*` (FilterBar/ProjectSearch) or the `LoginOrRegister` banner — ProjectList has its own inline search + sort ` handleStatusFilterChange(e.target.value)} - label="Status" - > - All - Pending - Approved - {/* Additional status options for judges */} - {applicationType === 'judge' && [ - Denied, - Verified Travel, - Confirmed, - Withdrew, - No Show - ]} - - - + {/* Filters and Controls — collapsed accordion on mobile, plain Paper on desktop */} + {(() => { + const filterGridItems = ( + <> + + handleFilterChange(e.target.value)} + placeholder="Name, email, organization..." + size="small" + /> + - {/* Checked In Filter - Show for all volunteer types */} - - - Checked In - - - + + + Status + + + - {/* In Person Filter - Only show for judges */} - {applicationType === 'judge' && ( - In Person + Checked In - )} - - - - Sort by - - - - - - - - - + {applicationType === 'judge' && ( + + + In Person + + + + )} - - handleShowBatchActionsChange(e.target.checked)} - /> - } - label="Batch Actions" - /> - - + + + LinkedIn + + + + + + + Sort by + + + + + + + + + + + + + handleShowBatchActionsChange(e.target.checked)} + /> + } + label="Batch Actions" + /> + + + ); - {/* Batch Actions */} - {currentShowBatchActions && ( + const batchActionsBlock = currentShowBatchActions && ( - {selectedApplications.size > 0 && ( <> + + + )} + {results.summary.errors.length > 0 && ( <> @@ -717,10 +1092,15 @@ const BatchEmailDialog = ({ size="small" onClick={() => setShowDetails(!showDetails)} > - {showDetails ? : } + {showDetails ? ( + + ) : ( + + )} - View Details ({results.summary.errors.length} errors) + View Details ({results.summary.errors.length}{" "} + errors) @@ -732,10 +1112,12 @@ const BatchEmailDialog = ({ {getResultIcon(result.success)} @@ -756,7 +1138,7 @@ const BatchEmailDialog = ({ {currentStep === 2 && results && ( @@ -779,11 +1161,14 @@ const BatchEmailDialog = ({ variant="outlined" color="secondary" disabled={ - testLoading || isScheduling || + testLoading || + isScheduling || !!BatchEmailService.validateMessage(messageText) || !!BatchEmailService.validateSubject(subject) } - startIcon={testLoading ? : } + startIcon={ + testLoading ? : + } > Send Test to questions@ohack.org @@ -791,13 +1176,16 @@ const BatchEmailDialog = ({ onClick={handleSendEmails} variant="contained" disabled={ - isScheduling || testLoading || + isScheduling || + testLoading || !!BatchEmailService.validateMessage(messageText) || !!BatchEmailService.validateSubject(subject) } startIcon={} > - {isScheduling ? 'Sending...' : `Send to ${eligibleUsers.length} Users`} + {isScheduling + ? "Sending..." + : `Send to ${eligibleUsers.length} Users`} )} @@ -806,4 +1194,4 @@ const BatchEmailDialog = ({ ); }; -export default BatchEmailDialog; \ No newline at end of file +export default BatchEmailDialog; diff --git a/src/components/admin/ContactSubmissionDetailDialog.js b/src/components/admin/ContactSubmissionDetailDialog.js index a78ed4ae..966cbd90 100644 --- a/src/components/admin/ContactSubmissionDetailDialog.js +++ b/src/components/admin/ContactSubmissionDetailDialog.js @@ -28,6 +28,23 @@ const statusOptions = [ { value: "responded", label: "Responded", color: "success" }, ]; +const INQUIRY_TYPE_DISPLAY = { + hackathon: "General Hackathon Inquiry", + sponsor: "Sponsorship Opportunity", + nonprofit: "Nonprofit Partnership", + judge: "Judging Opportunity", + volunteer: "Volunteer Opportunity", + volunteer_letter: "Volunteer Letter Request", + media: "Media Inquiry", + other: "General Question", +}; + +const buildContactSubject = (inquiryType) => { + const display = + INQUIRY_TYPE_DISPLAY[inquiryType] || inquiryType || "General Inquiry"; + return `Contact Us: ${display.toLowerCase()} - Opportunity Hack`; +}; + const formatDate = (dateStr) => { if (!dateStr) return "Not set"; try { @@ -219,6 +236,7 @@ const ContactSubmissionDetailDialog = ({ accessToken={accessToken} orgId={orgId} onEmailSent={onRefresh} + fixedSubject={buildContactSubject(submission.inquiryType)} /> {/* Metadata */} diff --git a/src/components/admin/CountdownManagement.js b/src/components/admin/CountdownManagement.js deleted file mode 100644 index 79d8ed37..00000000 --- a/src/components/admin/CountdownManagement.js +++ /dev/null @@ -1,265 +0,0 @@ -import React, { useState } from "react"; -import { - Typography, - Button, - List, - ListItem, - ListItemText, - ListItemSecondaryAction, - IconButton, - Dialog, - DialogTitle, - DialogContent, - DialogActions, - TextField, - Box, -} from "@mui/material"; -import { - Add as AddIcon, - Edit as EditIcon, - Delete as DeleteIcon, -} from "@mui/icons-material"; -import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker"; -import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; -import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns"; -import ReactMarkdown from "react-markdown"; -import TimezoneSelect from "react-timezone-select"; - -const CountdownManagement = ({ countdowns, onChange, eventTimezone = "America/Phoenix" }) => { - const [editingCountdown, setEditingCountdown] = useState(null); - const [dialogOpen, setDialogOpen] = useState(false); - const [selectedTimezone, setSelectedTimezone] = useState(eventTimezone); - - const formatDateForDisplay = (dateString, timezone) => { - const date = new Date(dateString); - return date.toLocaleString("en-US", { - timeZone: timezone, - dateStyle: "full", - timeStyle: "long", - }); - }; - - const formatDateForSave = (dateInput, timezone) => { - const date = dateInput instanceof Date ? dateInput : new Date(dateInput); - if (isNaN(date.getTime())) { - console.error("Invalid date input:", dateInput); - return ""; - } - - const pad = (num) => num.toString().padStart(2, "0"); - - // Use the Intl API to get the correct offset for the selected timezone - const formatter = new Intl.DateTimeFormat("en-US", { - timeZone: timezone, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, - timeZoneName: "short", - }); - const parts = formatter.formatToParts(date); - const dateParts = parts.reduce((acc, part) => { - acc[part.type] = part.value; - return acc; - }, {}); - - const year = dateParts.year; - const month = dateParts.month; - const day = dateParts.day; - const hour = dateParts.hour; - const minute = dateParts.minute; - const second = dateParts.second; - - // Extract the timezone offset - const timeZonePart = parts.find((part) => part.type === "timeZoneName"); - let offset; - if (timeZonePart) { - const offsetMatch = timeZonePart.value.match(/([+-])(\d{2})(\d{2})/); - if (offsetMatch) { - offset = offsetMatch[0]; - } else { - // Fallback for named time zones (e.g., MST) - const tzOffset = date.getTimezoneOffset(); - const offsetHours = pad(Math.abs(Math.floor(tzOffset / 60))); - const offsetMinutes = pad(Math.abs(tzOffset % 60)); - const offsetSign = tzOffset <= 0 ? "+" : "-"; - offset = `${offsetSign}${offsetHours}${offsetMinutes}`; - } - } else { - offset = "+0000"; // UTC if no offset found - } - - return `${year}-${month}-${day}T${hour}:${minute}:${second}${offset}`; - }; - - const handleAddCountdown = () => { - setEditingCountdown({ - name: "", - description: "", - time: new Date(), - timezone: selectedTimezone, - }); - setDialogOpen(true); - }; - - const handleEditCountdown = (countdown, index) => { - setEditingCountdown({ - ...countdown, - index, - time: new Date(countdown.time), - }); - setSelectedTimezone(countdown.timezone || eventTimezone); - setDialogOpen(true); - }; - - const handleDeleteCountdown = (index) => { - const newCountdowns = countdowns.filter((_, i) => i !== index); - onChange(newCountdowns); - }; - - const handleSaveCountdown = () => { - const formattedTime = formatDateForSave( - editingCountdown.time, - selectedTimezone - ); - - const savedCountdown = { - name: editingCountdown.name, - description: editingCountdown.description, - time: formattedTime, - }; - - let newCountdowns; - if (editingCountdown.index !== undefined) { - newCountdowns = countdowns.map((c, i) => - i === editingCountdown.index ? savedCountdown : c - ); - } else { - newCountdowns = [...countdowns, savedCountdown]; - } - - onChange(newCountdowns); - setDialogOpen(false); - }; - - const handleInputChange = (field, value) => { - setEditingCountdown((prev) => ({ - ...prev, - [field]: - field === "time" - ? value instanceof Date - ? value - : new Date(value) - : value, - })); - }; - - const handleTimezoneChange = (timezone) => { - setSelectedTimezone(timezone.value); - }; - - return ( - <> - - Countdowns - - - {countdowns.map((countdown, index) => ( - - - - handleEditCountdown(countdown, index)} - > - - - handleDeleteCountdown(index)} - > - - - - - ))} - - - - setDialogOpen(false)} - maxWidth="md" - fullWidth - > - - {editingCountdown?.index !== undefined - ? "Edit Countdown" - : "Add Countdown"} - - - handleInputChange("name", e.target.value)} - margin="normal" - /> - handleInputChange("description", e.target.value)} - margin="normal" - multiline - rows={4} - /> - - Preview: - - - {editingCountdown?.description || ""} - - - handleInputChange("time", date)} - renderInput={(params) => ( - - )} - /> - - - - Timezone - - - - - - - - - - - ); -}; - -export default CountdownManagement; diff --git a/src/components/admin/DonationManagement.js b/src/components/admin/DonationManagement.js deleted file mode 100644 index 6b4109cd..00000000 --- a/src/components/admin/DonationManagement.js +++ /dev/null @@ -1,136 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { TextField, Typography, Grid, Paper, Button, Box } from "@mui/material"; - -const DonationManagement = ({ initialDonationData, onDonationDataChange }) => { - const [donationData, setDonationData] = useState({ - donation_current: { - food: "0", - prize: "0", - swag: "0", - thank_you: "", - }, - donation_goals: { - food: "0", - prize: "0", - swag: "0", - }, - }); - - useEffect(() => { - if (initialDonationData) { - setDonationData(initialDonationData); - } - }, [initialDonationData]); - - const handleInputChange = (category, field, value) => { - setDonationData((prevData) => ({ - ...prevData, - [category]: { - ...prevData[category], - [field]: value, - }, - })); - }; - - const handleSave = () => { - onDonationDataChange(donationData); - }; - - return ( - - - Donation Management - - - - - Current Donations - - - handleInputChange("donation_current", "food", e.target.value) - } - margin="normal" - /> - - handleInputChange("donation_current", "prize", e.target.value) - } - margin="normal" - /> - - handleInputChange("donation_current", "swag", e.target.value) - } - margin="normal" - /> - - handleInputChange("donation_current", "thank_you", e.target.value) - } - margin="normal" - multiline - rows={2} - /> - - - - Donation Goals - - - handleInputChange("donation_goals", "food", e.target.value) - } - margin="normal" - /> - - handleInputChange("donation_goals", "prize", e.target.value) - } - margin="normal" - /> - - handleInputChange("donation_goals", "swag", e.target.value) - } - margin="normal" - /> - - - - - - - ); -}; - -export default DonationManagement; diff --git a/src/components/admin/EmailTemplateManager.js b/src/components/admin/EmailTemplateManager.js new file mode 100644 index 00000000..9c972ac1 --- /dev/null +++ b/src/components/admin/EmailTemplateManager.js @@ -0,0 +1,739 @@ +import React, { useMemo, useState, useCallback } from "react"; +import { + Alert, + Autocomplete, + Box, + Button, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Divider, + IconButton, + InputAdornment, + Stack, + TextField, + Tooltip, + Typography, + Paper, +} from "@mui/material"; +import { + Add as AddIcon, + Delete as DeleteIcon, + Edit as EditIcon, + History as HistoryIcon, + Restore as RestoreIcon, + Search as SearchIcon, + Clear as ClearIcon, + Refresh as RefreshIcon, +} from "@mui/icons-material"; +import useEmailTemplates from "../../hooks/use-email-templates"; +import { + detectPlaceholders, + PLACEHOLDER_LABELS, +} from "../../lib/messageTemplates"; +import * as ga from "../../lib/ga"; + +const KNOWN_ROLES = [ + "hacker", + "hackers", + "mentor", + "mentors", + "judge", + "judges", + "volunteer", + "volunteers", + "sponsor", + "sponsors", + "community members", + "community", + "slack", +]; + +const EMPTY_EDITOR = { + title: "", + category: "Custom", + category_key: "CUSTOM", + applicable_roles: [], + icon: "✉️", + message: "", + change_note: "", +}; + +const formatWhen = (iso) => { + if (!iso) return "—"; + try { + return new Date(iso).toLocaleString(); + } catch { + return iso; + } +}; + +const actorLabel = (actor) => + actor?.name || actor?.email || actor?.propel_user_id || "Unknown"; + +const EmailTemplateManager = ({ accessToken, orgId, onSnack }) => { + const { rawTemplates, loading, error, refresh } = useEmailTemplates({ + accessToken, + orgId, + }); + + const [search, setSearch] = useState(""); + const [editorOpen, setEditorOpen] = useState(false); + const [editorTarget, setEditorTarget] = useState(null); // null = create + const [editorDraft, setEditorDraft] = useState(EMPTY_EDITOR); + const [saving, setSaving] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(null); + const [historyTarget, setHistoryTarget] = useState(null); + const [historyVersions, setHistoryVersions] = useState(null); + const [historyLoading, setHistoryLoading] = useState(false); + const [confirmRevert, setConfirmRevert] = useState(null); + const [seeding, setSeeding] = useState(false); + + const apiBase = process.env.NEXT_PUBLIC_API_SERVER_URL; + const authHeaders = useMemo( + () => ({ + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + ...(orgId ? { "X-Org-Id": orgId } : {}), + }), + [accessToken, orgId], + ); + + const snack = useCallback( + (message, severity = "success") => { + if (onSnack) onSnack(message, severity); + }, + [onSnack], + ); + + const categories = useMemo(() => { + const seen = new Map(); + (rawTemplates || []).forEach((t) => { + const key = t.category_key || "CUSTOM"; + if (!seen.has(key)) seen.set(key, t.category || "Custom"); + }); + return seen; + }, [rawTemplates]); + + const groupedRows = useMemo(() => { + const q = search.trim().toLowerCase(); + const filtered = (rawTemplates || []).filter((t) => { + if (!q) return true; + return [t.title, t.message, t.category, ...(t.applicable_roles || [])] + .filter(Boolean) + .join(" ") + .toLowerCase() + .includes(q); + }); + const groups = new Map(); + filtered.forEach((t) => { + const key = t.category_key || "CUSTOM"; + if (!groups.has(key)) { + groups.set(key, { category: t.category || "Custom", templates: [] }); + } + groups.get(key).templates.push(t); + }); + return [...groups.entries()]; + }, [rawTemplates, search]); + + const openCreate = () => { + setEditorTarget(null); + setEditorDraft(EMPTY_EDITOR); + setEditorOpen(true); + }; + + const openEdit = (template) => { + setEditorTarget(template); + setEditorDraft({ + title: template.title || "", + category: template.category || "Custom", + category_key: template.category_key || "CUSTOM", + applicable_roles: template.applicable_roles || [], + icon: template.icon || "✉️", + message: template.message || "", + change_note: "", + }); + setEditorOpen(true); + }; + + const handleSave = async () => { + setSaving(true); + try { + const isCreate = !editorTarget; + const url = isCreate + ? `${apiBase}/api/admin/templates` + : `${apiBase}/api/admin/templates/${editorTarget.id}`; + const res = await fetch(url, { + method: isCreate ? "POST" : "PATCH", + headers: authHeaders, + body: JSON.stringify(editorDraft), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.text || `Save failed (${res.status})`); + } + ga.trackStructuredEvent( + ga.EventCategory.ADMIN, + isCreate ? "admin_template_create" : "admin_template_edit", + isCreate ? editorDraft.title : editorTarget.id, + ); + snack(isCreate ? "Template created" : "Template saved"); + setEditorOpen(false); + await refresh(true); + } catch (err) { + console.error("Template save failed:", err); + snack(err.message || "Save failed", "error"); + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + const target = confirmDelete; + setConfirmDelete(null); + if (!target) return; + try { + const res = await fetch(`${apiBase}/api/admin/templates/${target.id}`, { + method: "DELETE", + headers: authHeaders, + }); + if (!res.ok) throw new Error(`Delete failed (${res.status})`); + ga.trackStructuredEvent( + ga.EventCategory.ADMIN, + "admin_template_delete", + target.id, + ); + snack(`Deleted "${target.title}"`); + await refresh(true); + } catch (err) { + console.error("Template delete failed:", err); + snack(err.message || "Delete failed", "error"); + } + }; + + const openHistory = async (template) => { + setHistoryTarget(template); + setHistoryVersions(null); + setHistoryLoading(true); + try { + const res = await fetch( + `${apiBase}/api/admin/templates/${template.id}/versions`, + { headers: authHeaders }, + ); + if (!res.ok) throw new Error(`Failed to load history (${res.status})`); + const data = await res.json(); + setHistoryVersions(data.text || []); + } catch (err) { + console.error("Template history fetch failed:", err); + snack(err.message || "Failed to load history", "error"); + setHistoryTarget(null); + } finally { + setHistoryLoading(false); + } + }; + + const handleRevert = async () => { + const target = confirmRevert; + setConfirmRevert(null); + if (!target || !historyTarget) return; + try { + const res = await fetch( + `${apiBase}/api/admin/templates/${historyTarget.id}/revert`, + { + method: "POST", + headers: authHeaders, + body: JSON.stringify({ version: target.version }), + }, + ); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.text || `Revert failed (${res.status})`); + } + ga.trackStructuredEvent( + ga.EventCategory.ADMIN, + "admin_template_revert", + `${historyTarget.id}@${target.version}`, + ); + snack(`Reverted "${historyTarget.title}" to version ${target.version}`); + setHistoryTarget(null); + await refresh(true); + } catch (err) { + console.error("Template revert failed:", err); + snack(err.message || "Revert failed", "error"); + } + }; + + const handleSeed = async () => { + setSeeding(true); + try { + const res = await fetch(`${apiBase}/api/admin/templates/seed`, { + method: "POST", + headers: authHeaders, + }); + if (!res.ok) throw new Error(`Restore failed (${res.status})`); + const data = await res.json(); + snack(data.text || "Defaults restored"); + await refresh(true); + } catch (err) { + console.error("Template seed failed:", err); + snack(err.message || "Restore failed", "error"); + } finally { + setSeeding(false); + } + }; + + const draftPlaceholders = detectPlaceholders(editorDraft.message); + + return ( + + + setSearch(e.target.value)} + sx={{ minWidth: { sm: 320 } }} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: search ? ( + + setSearch("")}> + + + + ) : null, + }} + /> + + + + + + + + + + + + + + + + + These templates power the email buttons in the Volunteer admin sections + (single and batch sends). Edits are versioned — use the history action + to see who changed what and to roll back. + + + {error && ( + + {error} — the send-email dialogs will fall back to the built-in + templates until this loads. + + )} + + {loading && !rawTemplates && ( + + + + )} + + {rawTemplates && groupedRows.length === 0 && ( + + No templates match your search. + + )} + + {groupedRows.map(([categoryKey, group]) => ( + + + {group.category} + + + {group.templates.map((template) => ( + + + + + {template.icon} + + {template.title} + + + {(template.applicable_roles || []) + .slice(0, 4) + .map((role) => ( + + ))} + {(template.applicable_roles || []).length > 4 && ( + + )} + + + {template.message} + + + Updated {formatWhen(template.updated_at)} by{" "} + {actorLabel(template.updated_by)} + + + + + openEdit(template)} + > + + + + + openHistory(template)} + > + + + + + setConfirmDelete(template)} + > + + + + + + + ))} + + + ))} + + {/* Create / Edit dialog */} + !saving && setEditorOpen(false)} + maxWidth="md" + fullWidth + > + + {editorTarget ? `Edit "${editorTarget.title}"` : "New template"} + + + + + + setEditorDraft((d) => ({ ...d, title: e.target.value })) + } + fullWidth + required + /> + + setEditorDraft((d) => ({ ...d, icon: e.target.value })) + } + sx={{ width: 90 }} + /> + + + { + const existingKey = [...categories.entries()].find( + ([, label]) => label === value, + )?.[0]; + setEditorDraft((d) => ({ + ...d, + category: value, + category_key: + existingKey || + value + .toUpperCase() + .replace(/[^A-Z0-9]+/g, "_") + .replace(/^_|_$/g, "") || + "CUSTOM", + })); + }} + renderInput={(params) => ( + + )} + fullWidth + /> + + setEditorDraft((d) => ({ ...d, applicable_roles: value })) + } + renderInput={(params) => ( + + )} + fullWidth + /> + + + setEditorDraft((d) => ({ ...d, message: e.target.value })) + } + multiline + rows={14} + required + helperText="Placeholders like [EVENT_ID], [VOLUNTEER_ID], [VOLUNTEER_TYPE] are auto-filled at send time; any other [UPPERCASE] placeholder prompts the sender for a value." + /> + {draftPlaceholders.length > 0 && ( + + + Manual placeholders the sender will be asked to fill: + + + {draftPlaceholders.map((name) => ( + + ))} + + + )} + {editorTarget && ( + + setEditorDraft((d) => ({ ...d, change_note: e.target.value })) + } + helperText="Shown in the version history, e.g. 'Updated venue details for Summer 2026'" + /> + )} + + + + + + + + + {/* Delete confirmation */} + setConfirmDelete(null)}> + Delete template? + + + This permanently removes "{confirmDelete?.title}" and its entire + version history. + {confirmDelete?.origin === "seed" && + ' It is one of the original defaults, so "Restore defaults" can bring back the original version later (history will restart at v1).'} + + + + + + + + + {/* Version history dialog */} + setHistoryTarget(null)} + maxWidth="md" + fullWidth + > + Version history — {historyTarget?.title} + + {historyLoading && ( + + + + )} + {historyVersions && historyVersions.length === 0 && ( + + No history recorded yet. + + )} + + {(historyVersions || []).map((v) => { + const isCurrent = v.version === historyTarget?.version; + return ( + + + + + {isCurrent && ( + + )} + + {formatWhen(v.updated_at)} · {actorLabel(v.updated_by)} + {v.change_note ? ` · ${v.change_note}` : ""} + + + {!isCurrent && ( + + )} + + {v.title} + + + {v.message} + + + ); + })} + + + + + + + + {/* Revert confirmation */} + setConfirmRevert(null)}> + Revert to version {confirmRevert?.version}? + + + The content of version {confirmRevert?.version} will be saved as a + new version. Nothing is deleted — you can always revert again. + + + + + + + + + ); +}; + +export default EmailTemplateManager; diff --git a/src/components/admin/EventMediaManagement.js b/src/components/admin/EventMediaManagement.js new file mode 100644 index 00000000..9c761898 --- /dev/null +++ b/src/components/admin/EventMediaManagement.js @@ -0,0 +1,548 @@ +import React, { useCallback, useRef, useState } from "react"; +import { + Alert, + Box, + Button, + Card, + CardContent, + CardMedia, + CircularProgress, + Divider, + FormControl, + Grid, + IconButton, + InputLabel, + LinearProgress, + MenuItem, + Paper, + Select, + Stack, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import { + ArrowDownward as ArrowDownIcon, + ArrowUpward as ArrowUpIcon, + Article as ArticleIcon, + CloudUpload as UploadIcon, + Delete as DeleteIcon, + Image as ImageIcon, + Instagram as InstagramIcon, + LinkedIn as LinkedInIcon, + AlternateEmail as ThreadsIcon, + OpenInNew as OpenInNewIcon, + Add as AddIcon, +} from "@mui/icons-material"; + +const PLATFORMS = [ + { + value: "linkedin", + label: "LinkedIn", + host: "linkedin.com", + icon: LinkedInIcon, + }, + { + value: "instagram", + label: "Instagram", + host: "instagram.com", + icon: InstagramIcon, + }, + { + value: "threads", + label: "Threads", + host: "threads.net", + icon: ThreadsIcon, + }, + { + value: "article", + label: "News article", + icon: ArticleIcon, + placeholder: "https://www.ohack.dev/blog/...", + }, +]; + +const platformByValue = (value) => PLATFORMS.find((p) => p.value === value); + +const isHostMatch = (urlString, platform) => { + if (!urlString) return false; + try { + const u = new URL(urlString); + if (u.protocol !== "http:" && u.protocol !== "https:") { + return false; + } + + if (platform === "article") { + return true; + } + + const host = u.hostname.replace(/^www\./, "").toLowerCase(); + if (platform === "threads") { + return ( + host === "threads.net" || + host.endsWith(".threads.net") || + host === "threads.com" || + host.endsWith(".threads.com") + ); + } + const expected = platformByValue(platform)?.host; + return !!expected && (host === expected || host.endsWith(`.${expected}`)); + } catch { + return false; + } +}; + +const EventMediaManagement = ({ + eventId, + accessToken, + orgId, + photos = [], + onPhotosChange, + socialPosts = [], + onSocialPostsChange, +}) => { + const fileInputRef = useRef(null); + const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState({ + current: 0, + total: 0, + }); + const [uploadError, setUploadError] = useState(""); + + const canUpload = !!eventId && !!accessToken && !!orgId; + const directory = eventId ? `hackathons/${eventId}/photos` : ""; + + const uploadOne = useCallback( + async (file) => { + const formData = new FormData(); + formData.append("file", file); + formData.append("directory", directory); + const timestamp = Date.now(); + const cleanFilename = file.name.replace(/[^a-zA-Z0-9.-]/g, "_"); + formData.append("filename", `${timestamp}_${cleanFilename}`); + + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/messages/upload-image`, + { + method: "POST", + body: formData, + headers: { + authorization: `Bearer ${accessToken}`, + "X-Org-Id": orgId, + }, + }, + ); + const json = await res.json(); + if (!json.success) { + throw new Error(json.error || `Upload failed for ${file.name}`); + } + return json.url; + }, + [accessToken, orgId, directory], + ); + + const handleFiles = useCallback( + async (fileList) => { + const files = Array.from(fileList || []); + if (!files.length) return; + + const allowed = [ + "image/jpeg", + "image/jpg", + "image/png", + "image/gif", + "image/webp", + ]; + const valid = files.filter((f) => { + if (!allowed.includes(f.type)) { + setUploadError(`${f.name}: not a supported image type`); + return false; + } + if (f.size > 20 * 1024 * 1024) { + setUploadError(`${f.name}: exceeds 20MB`); + return false; + } + return true; + }); + if (!valid.length) return; + + setUploadError(""); + setIsUploading(true); + setUploadProgress({ current: 0, total: valid.length }); + + const uploaded = []; + for (let i = 0; i < valid.length; i++) { + try { + const url = await uploadOne(valid[i]); + uploaded.push({ url, caption: "", credit: "" }); + setUploadProgress({ current: i + 1, total: valid.length }); + } catch (err) { + setUploadError(err.message || "Upload failed"); + } + } + + if (uploaded.length) { + onPhotosChange([...(photos || []), ...uploaded]); + } + setIsUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + }, + [uploadOne, onPhotosChange, photos], + ); + + const updatePhoto = (index, patch) => { + const next = photos.map((p, i) => (i === index ? { ...p, ...patch } : p)); + onPhotosChange(next); + }; + + const removePhoto = (index) => { + onPhotosChange(photos.filter((_, i) => i !== index)); + }; + + const movePhoto = (index, delta) => { + const target = index + delta; + if (target < 0 || target >= photos.length) return; + const next = [...photos]; + [next[index], next[target]] = [next[target], next[index]]; + onPhotosChange(next); + }; + + const addSocialPost = () => { + onSocialPostsChange([ + ...(socialPosts || []), + { platform: "linkedin", url: "", caption: "" }, + ]); + }; + + const updateSocialPost = (index, patch) => { + const next = socialPosts.map((p, i) => + i === index ? { ...p, ...patch } : p, + ); + onSocialPostsChange(next); + }; + + const removeSocialPost = (index) => { + onSocialPostsChange(socialPosts.filter((_, i) => i !== index)); + }; + + return ( + + + Event Photos + + + Photos appear on /hack/{eventId || "[event_id]"}/media and + a small teaser strip on the event page. Files are uploaded to{" "} + cdn.ohack.dev/hackathons/{eventId || "[event_id]"}/photos/. + + + {!eventId && ( + + Set the Event ID on the Basic Info tab and save before uploading + photos. + + )} + + {!!eventId && !accessToken && ( + + Sign-in required for uploads. + + )} + + + canUpload && !isUploading && fileInputRef.current?.click() + } + onDragOver={(e) => { + if (canUpload && !isUploading) e.preventDefault(); + }} + onDrop={(e) => { + if (!canUpload || isUploading) return; + e.preventDefault(); + handleFiles(e.dataTransfer.files); + }} + > + handleFiles(e.target.files)} + style={{ display: "none" }} + /> + {isUploading ? ( + + + + Uploading {uploadProgress.current} of {uploadProgress.total}… + + + + ) : ( + + + + Drop images here or click to browse + + + JPG, PNG, GIF, WebP up to 10MB each. You can select multiple at + once. + + + + )} + + + {uploadError && ( + setUploadError("")} + > + {uploadError} + + )} + + {photos.length > 0 && ( + + {photos.map((photo, index) => ( + + + + + + updatePhoto(index, { caption: e.target.value }) + } + sx={{ mb: 1 }} + /> + + updatePhoto(index, { credit: e.target.value }) + } + /> + + + + + movePhoto(index, -1)} + disabled={index === 0} + > + + + + + + + movePhoto(index, 1)} + disabled={index === photos.length - 1} + > + + + + + + + removePhoto(index)} + > + + + + + + + + ))} + + )} + + + + + Social Posts & News + + + Link to LinkedIn, Instagram, or Threads posts, plus news articles about + this event. Instagram posts embed inline; LinkedIn, Threads, and + articles render as link cards. + + + + {socialPosts.map((post, index) => { + const platform = platformByValue(post.platform) || PLATFORMS[0]; + const PlatformIcon = platform.icon; + const urlValid = !post.url || isHostMatch(post.url, post.platform); + return ( + + + + + Platform + + + + + + updateSocialPost(index, { url: e.target.value }) + } + error={!urlValid} + helperText={ + !urlValid + ? platform.value === "article" + ? "Enter a full http(s) article URL" + : `URL host should match ${platform.label}` + : " " + } + InputProps={{ + startAdornment: ( + + ), + }} + /> + + updateSocialPost(index, { caption: e.target.value }) + } + sx={{ mt: 1 }} + /> + + + + {post.url && urlValid && ( + + + + + + )} + + removeSocialPost(index)} + > + + + + + + + + ); + })} + + + + + ); +}; + +export default EventMediaManagement; diff --git a/src/components/admin/HackathonDuplicator.js b/src/components/admin/HackathonDuplicator.js index 811fb994..346d6888 100644 --- a/src/components/admin/HackathonDuplicator.js +++ b/src/components/admin/HackathonDuplicator.js @@ -44,6 +44,15 @@ const HackathonDuplicator = ({ swag: "0", thank_you: "", }, + // Planning board: subcollections are not copied to the duplicate. + // Reset to defaults so the new event starts disabled with no orphaned editors / Slack settings. + planning: { + enabled: false, + editors: [], + slack: { channel: "", notify_on_card_change: false }, + template_seeded: false, + budget_widget_on_event_page: false, + }, }; setNewHackathon(duplicatedHackathon); diff --git a/src/components/admin/HackerDepositBulkRefundDialog.js b/src/components/admin/HackerDepositBulkRefundDialog.js new file mode 100644 index 00000000..bcc716db --- /dev/null +++ b/src/components/admin/HackerDepositBulkRefundDialog.js @@ -0,0 +1,290 @@ +import React, { useMemo, useState, useEffect } from "react"; +import { + Alert, + AlertTitle, + Box, + Button, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from "@mui/material"; + +const formatDollars = (cents) => + Number.isFinite(cents) ? `$${(cents / 100).toFixed(2)}` : "—"; + +// Three-phase flow: preview (show eligible list + confirm), running (spinner), +// done (per-row results). Splitting into discrete phases keeps the CTA +// unambiguous and lets the post-action results stay on screen until the admin +// closes — they can copy/paste the failure list if anything went wrong. +const HackerDepositBulkRefundDialog = ({ + open, + onClose, + eligibleHackers, + eventId, + apiServerUrl, + accessToken, + orgId, + onComplete, +}) => { + const [phase, setPhase] = useState("preview"); // preview | running | done + const [result, setResult] = useState(null); + const [error, setError] = useState(""); + + useEffect(() => { + if (open) { + setPhase("preview"); + setResult(null); + setError(""); + } + }, [open]); + + const totalCents = useMemo( + () => + (eligibleHackers || []).reduce( + (sum, h) => sum + (h.deposit_amount_cents || 0), + 0, + ), + [eligibleHackers], + ); + + const count = (eligibleHackers || []).length; + + const handleRun = async () => { + if (!apiServerUrl || !accessToken) { + setError("Missing API credentials. Refresh and try again."); + return; + } + setPhase("running"); + setError(""); + try { + const res = await fetch( + `${apiServerUrl}/api/admin/hackathon/${eventId}/refund-eligible-deposits`, + { + method: "POST", + headers: { + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + "X-Org-Id": orgId, + }, + body: JSON.stringify({}), + }, + ); + const payload = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error( + payload?.message || payload?.error || `Bulk refund failed (${res.status})`, + ); + } + setResult(payload?.data || payload); + setPhase("done"); + onComplete?.(); + } catch (e) { + setError(e.message || "Bulk refund failed."); + setPhase("preview"); + } + }; + + const renderPreview = () => ( + <> + {count === 0 ? ( + + No hackers are eligible for refund right now. Eligible means deposit + status is paid and disposition is{" "} + refund. Donated and refund-failed rows are not + included — handle those individually. + + ) : ( + <> + + This issues real Stripe refunds + About to refund {count} hacker{count === 1 ? "" : "s"}{" "} + for {formatDollars(totalCents)} total. Refunds + typically settle in 5–10 business days and cannot be undone from + this UI. Donated and refund-failed rows are excluded — handle + those per-row. + + + + + + Name + Email + Amount + + + + {eligibleHackers.map((h) => ( + + {h.name || "—"} + {h.email || "—"} + + {formatDollars(h.deposit_amount_cents)} + + + ))} + +
+
+ + )} + + ); + + const renderRunning = () => ( + + + + Refunding {count} deposit{count === 1 ? "" : "s"}… + + + Each refund is one Stripe API call. Don't close this window. + + + ); + + const renderDone = () => { + if (!result) return null; + const refunded = result.refunded || []; + const failed = result.failed || []; + return ( + + + + {failed.length === 0 + ? "All refunds succeeded" + : `${refunded.length} refunded, ${failed.length} failed`} + + Total refunded: {formatDollars(result.total_amount_cents)}.{" "} + {failed.length > 0 && + "Failed rows are listed below — retry from the per-row chip after investigating."} + + + {refunded.length > 0 && ( + + + Refunded ({refunded.length}) + + + + + {refunded.map((r) => ( + + {r.name} + + {formatDollars(r.amount_cents)} + + + + + + ))} + +
+
+
+ )} + + {failed.length > 0 && ( + + + Failed ({failed.length}) + + + + + {failed.map((f) => ( + + + {f.name} + + + {f.error} + + + ))} + +
+
+
+ )} +
+ ); + }; + + return ( + + + Bulk refund eligible deposits + {eventId && ( + + {eventId} + + )} + + + + {phase === "preview" && renderPreview()} + {phase === "running" && renderRunning()} + {phase === "done" && renderDone()} + {error && ( + + {error} + + )} + + + + {phase === "preview" && count > 0 && ( + + )} + + + ); +}; + +export default HackerDepositBulkRefundDialog; diff --git a/src/components/admin/HackerDepositChip.js b/src/components/admin/HackerDepositChip.js new file mode 100644 index 00000000..9b49f8fd --- /dev/null +++ b/src/components/admin/HackerDepositChip.js @@ -0,0 +1,98 @@ +import React from "react"; +import { Chip, Tooltip } from "@mui/material"; +import CheckCircleIcon from "@mui/icons-material/CheckCircle"; +import WarningAmberIcon from "@mui/icons-material/WarningAmber"; +import VolunteerActivismIcon from "@mui/icons-material/VolunteerActivism"; +import ReplyIcon from "@mui/icons-material/Reply"; +import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline"; + +// Derive the deposit display state from a hacker volunteer doc. Status priority: +// 1. refund_failed beats everything (sticky error until next attempt) +// 2. refunded +// 3. donated (disposition-driven while still in "paid") +// 4. paid +// 5. unpaid (no payment intent recorded) +export const getDepositState = (volunteer) => { + const status = volunteer?.deposit_status; + const piid = volunteer?.stripe_payment_intent_id; + const disposition = volunteer?.deposit_disposition; + const amountCents = + volunteer?.deposit_refund_amount_cents ?? volunteer?.deposit_amount_cents; + + if (status === "refund_failed") return { kind: "refund_failed", amountCents }; + if (status === "refunded") return { kind: "refunded", amountCents }; + if (piid && disposition === "donate") return { kind: "donated", amountCents }; + if (piid) return { kind: "paid", amountCents }; + return { kind: "unpaid", amountCents: null }; +}; + +const formatDollars = (cents) => { + if (!Number.isFinite(cents)) return ""; + return `$${(cents / 100).toFixed(2)}`; +}; + +const CONFIG = { + paid: { + label: (cents) => `Paid ${formatDollars(cents)}`, + color: "success", + icon: , + tooltip: "Deposit paid. Click to issue a refund.", + }, + unpaid: { + label: () => "Unpaid", + color: "warning", + icon: , + tooltip: + "Deposit required for this event but no payment recorded. Chase the hacker.", + }, + donated: { + label: (cents) => `Donated ${formatDollars(cents)}`, + color: "primary", + variant: "outlined", + icon: , + tooltip: + "Hacker chose to donate their deposit. Click to override and refund anyway.", + }, + refunded: { + label: (cents) => `Refunded ${formatDollars(cents)}`, + color: "default", + icon: , + tooltip: "Deposit was refunded. Click for details.", + }, + refund_failed: { + label: () => "Refund failed", + color: "error", + icon: , + tooltip: + "Stripe rejected the refund. Click to see the error and retry.", + }, +}; + +const HackerDepositChip = ({ volunteer, onClick }) => { + const state = getDepositState(volunteer); + const cfg = CONFIG[state.kind]; + const clickable = state.kind !== "unpaid" || Boolean(onClick); + const tooltip = + state.kind === "refund_failed" && volunteer?.deposit_refund_status_msg + ? `Stripe error: ${volunteer.deposit_refund_status_msg}` + : cfg.tooltip; + + return ( + + + + ); +}; + +export default HackerDepositChip; diff --git a/src/components/admin/HackerDepositRefundDialog.js b/src/components/admin/HackerDepositRefundDialog.js new file mode 100644 index 00000000..fb1b3d8a --- /dev/null +++ b/src/components/admin/HackerDepositRefundDialog.js @@ -0,0 +1,288 @@ +import React, { useState, useMemo } from "react"; +import { + Alert, + AlertTitle, + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + Link, + Stack, + Typography, +} from "@mui/material"; +import { getDepositState } from "./HackerDepositChip"; + +const formatDollars = (cents) => + Number.isFinite(cents) ? `$${(cents / 100).toFixed(2)}` : "—"; + +const formatTs = (ts) => { + if (!ts) return "—"; + try { + return new Date(ts).toLocaleString(); + } catch { + return ts; + } +}; + +// Centralizes the conditional copy and CTA wiring per deposit state. Returning +// JSX in this map keeps the render below flat and scannable. +const HackerDepositRefundDialog = ({ + open, + onClose, + volunteer, + apiServerUrl, + accessToken, + orgId, + onRefunded, +}) => { + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(""); + const [overrideArmed, setOverrideArmed] = useState(false); + + const state = useMemo( + () => (volunteer ? getDepositState(volunteer) : { kind: "unpaid" }), + [volunteer], + ); + + // Reset state when the dialog opens for a different volunteer. + React.useEffect(() => { + if (open) { + setError(""); + setOverrideArmed(false); + setSubmitting(false); + } + }, [open, volunteer?.id]); + + if (!volunteer) return null; + + const isDonate = state.kind === "donated"; + const isRefunded = state.kind === "refunded"; + const isFailed = state.kind === "refund_failed"; + const isUnpaid = state.kind === "unpaid"; + const canRefund = !isUnpaid && !isRefunded; + + const handleRefund = async () => { + if (!apiServerUrl || !accessToken) { + setError("Missing API credentials. Refresh and try again."); + return; + } + setSubmitting(true); + setError(""); + try { + const res = await fetch( + `${apiServerUrl}/api/admin/hacker/${volunteer.id}/refund-deposit`, + { + method: "POST", + headers: { + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + "X-Org-Id": orgId, + }, + body: JSON.stringify({ override: isDonate }), + }, + ); + + const payload = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error( + payload?.message || payload?.error || `Refund failed (${res.status})`, + ); + } + onRefunded?.(payload?.data || payload); + onClose(); + } catch (e) { + setError(e.message || "Refund failed."); + } finally { + setSubmitting(false); + } + }; + + const renderSummary = () => ( + + + Hacker + + + {volunteer.name || volunteer.firstName || "(no name)"} + + {volunteer.email && ( + + {volunteer.email} + + )} + + + + + + + Deposit paid + + + {formatDollars(volunteer.deposit_amount_cents)} + + + + + Hacker's choice + + + {volunteer.deposit_disposition === "donate" + ? "Donate to OHack" + : volunteer.deposit_disposition === "refund" + ? "Refund after event" + : "—"} + + + + + Stripe PaymentIntent + + + {volunteer.stripe_payment_intent_id || "—"} + + + + + ); + + const renderAuditTrail = () => { + if (!isRefunded && !isFailed) return null; + return ( + + + Refund history + + + {isRefunded && ( + <> + + Refunded {formatDollars(volunteer.deposit_refund_amount_cents)}{" "} + on {formatTs(volunteer.deposit_refunded_at)} + + + By admin {volunteer.deposit_refunded_by || "—"} + + {volunteer.deposit_refund_id && ( + + {volunteer.deposit_refund_id}{" "} + + (view in Stripe) + + + )} + + )} + {isFailed && volunteer.deposit_refund_status_msg && ( + + Last attempt failed + {volunteer.deposit_refund_status_msg} + + )} + + + ); + }; + + const renderActionArea = () => { + if (isUnpaid) { + return ( + + No deposit recorded for this hacker. Nothing to refund. + + ); + } + + if (isRefunded) { + return ( + + This deposit has already been refunded. + + ); + } + + if (isDonate && !overrideArmed) { + return ( + + Hacker chose to donate + We don't normally refund donations. If you have a specific reason + (financial hardship, admin error), you can override this and issue a + refund anyway. + + + + + ); + } + + return ( + + This issues a real Stripe refund + Clicking "Refund {formatDollars(volunteer.deposit_amount_cents)}" sends + the money back to the hacker's card via Stripe. Refunds typically settle + in 5–10 business days and cannot be undone from this UI. + + ); + }; + + const refundButtonVisible = + canRefund && (!isDonate || overrideArmed) && !isRefunded; + + return ( + + Hacker deposit + + {renderSummary()} + {renderActionArea()} + {renderAuditTrail()} + {error && ( + + {error} + + )} + + + + {refundButtonVisible && ( + + )} + + + ); +}; + +export default HackerDepositRefundDialog; diff --git a/src/components/admin/NonprofitManagement.js b/src/components/admin/NonprofitManagement.js index 033340ca..7c6b3075 100644 --- a/src/components/admin/NonprofitManagement.js +++ b/src/components/admin/NonprofitManagement.js @@ -23,11 +23,9 @@ import { Chip, Grid, Divider, - FormControl, - InputLabel, - Select, - MenuItem, Alert, + Autocomplete, + Tooltip, } from "@mui/material"; import { ExpandMore as ExpandMoreIcon, @@ -36,9 +34,11 @@ import { Save as SaveIcon, Search as SearchIcon, Edit as EditIcon, + OpenInNew as OpenInNewIcon, Visibility as VisibilityIcon, VisibilityOff as VisibilityOffIcon, } from "@mui/icons-material"; +import { useRouter } from "next/router"; import axios from "axios"; import ProblemStatementManagement from "./ProblemStatementManagement"; @@ -50,12 +50,12 @@ const NonprofitManagement = memo(({ onUpdate = () => {}, onError = () => {} }) => { + const router = useRouter(); const [nonprofits, setNonprofits] = useState([]); const [hackathonNonprofits, setHackathonNonprofits] = useState([]); const [problemStatements, setProblemStatements] = useState([]); const [loading, setLoading] = useState(false); - const [searchTerm, setSearchTerm] = useState(""); - const [selectedNonprofitId, setSelectedNonprofitId] = useState(""); + const [nonprofitToAdd, setNonprofitToAdd] = useState(null); const [problemStatementDialogOpen, setProblemStatementDialogOpen] = useState(false); const [selectedNonprofit, setSelectedNonprofit] = useState(null); const [visiblePsIds, setVisiblePsIds] = useState(hackathon?.visible_problem_statements || null); @@ -210,7 +210,7 @@ const NonprofitManagement = memo(({ // Add a nonprofit to the hackathon with improved state handling const addNonprofitToHackathon = async () => { - if (!selectedNonprofitId || !hackathon?.id || isLoadingRef.current) return; + if (!nonprofitToAdd?.id || !hackathon?.id || isLoadingRef.current) return; isLoadingRef.current = true; setLoading(true); @@ -220,7 +220,7 @@ const NonprofitManagement = memo(({ `${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/messages/hackathon/nonprofit`, { hackathonId: hackathon.id, - nonprofitId: selectedNonprofitId + nonprofitId: nonprofitToAdd.id }, { headers: { @@ -232,7 +232,7 @@ const NonprofitManagement = memo(({ ); if (response.status === 200) { - setSelectedNonprofitId(""); + setNonprofitToAdd(null); onUpdate(); // Only refetch if the current hackathon ID matches @@ -357,11 +357,6 @@ const NonprofitManagement = memo(({ nonprofit => !hackathonNonprofits.some(hn => hn.id === nonprofit.id) ); - // Filter nonprofits based on search term - const filteredNonprofits = availableNonprofits.filter( - nonprofit => nonprofit.name.toLowerCase().includes(searchTerm.toLowerCase()) - ); - // Get problem statements for a specific nonprofit const getNonprofitProblemStatements = (nonprofitId) => { if (!nonprofitId) return []; @@ -392,55 +387,81 @@ const NonprofitManagement = memo(({ Add Nonprofit to Hackathon - + - - Select Nonprofit - - + option.name || ""} + isOptionEqualToValue={(option, value) => option.id === value.id} + value={nonprofitToAdd} + onChange={(_, newValue) => setNonprofitToAdd(newValue)} + filterOptions={(options, { inputValue }) => { + const lc = inputValue.toLowerCase(); + if (!lc) return options; + return options.filter( + (o) => + o.name?.toLowerCase().includes(lc) || + o.description?.toLowerCase().includes(lc) + ); + }} + renderInput={(params) => ( + + + {params.InputProps.startAdornment} + + ), + }} + /> + )} + renderOption={(props, option) => ( + + + + {option.name} + + {option.description && ( + + {option.description.length > 110 + ? option.description.slice(0, 110) + "…" + : option.description} + + )} + + + )} + noOptionsText={ + loading ? "Loading…" : "No nonprofits available to add" + } + loading={loading} + fullWidth + /> - - {/* Search filter */} - - setSearchTerm(e.target.value)} - fullWidth - InputProps={{ - startAdornment: , - }} - /> - {/* Project visibility save bar */} @@ -503,9 +524,23 @@ const NonprofitManagement = memo(({ return ( }> - - {nonprofit.name || "Unnamed Nonprofit"} - + + + {nonprofit.name || "Unnamed Nonprofit"} + + + { + e.stopPropagation(); + router.push(`/admin/nonprofit?id=${nonprofit.id}&from=${encodeURIComponent(`/admin/hackathons/${hackathon?.event_id || ''}`)}`); + }} + aria-label={`Edit ${nonprofit.name}`} + > + + + + diff --git a/src/components/admin/SocialMediaManagement.js b/src/components/admin/SocialMediaManagement.js index 2dc06003..b710e544 100644 --- a/src/components/admin/SocialMediaManagement.js +++ b/src/components/admin/SocialMediaManagement.js @@ -91,6 +91,8 @@ const SocialMediaManagement = ({ onSnackbar }) => { const [selectedSlackUsers, setSelectedSlackUsers] = useState(new Set()); const [slackSearchFilter, setSlackSearchFilter] = useState(''); const [showSlackBrowser, setShowSlackBrowser] = useState(false); + const [slackPasteInput, setSlackPasteInput] = useState(''); + const [slackPasteFeedback, setSlackPasteFeedback] = useState(null); // Settings state const [settings, setSettings] = useState({ @@ -342,6 +344,37 @@ const SocialMediaManagement = ({ onSnackbar }) => { return [...new Set(emails)]; // Remove duplicates }; + const normalizeSlackLookupToken = (value) => { + if (!value) return ''; + + let normalized = value.trim().replace(/^['"]+|['"]+$/g, ''); + if (!normalized) return ''; + + const slackMentionMatch = normalized.match(/^<@([A-Z0-9]+)>$/i); + if (slackMentionMatch) { + return slackMentionMatch[1].toLowerCase(); + } + + const embeddedEmailMatch = normalized.match(/]+@[^\s<>]+)>?/); + if (embeddedEmailMatch) { + return embeddedEmailMatch[1].toLowerCase(); + } + + normalized = normalized.replace(/^@/, ''); + return normalized.toLowerCase(); + }; + + const parseSlackLookupInput = (text) => { + if (!text) return []; + + return [...new Set( + text + .split(/[\n,;]+/) + .map(token => token.trim()) + .filter(Boolean) + )]; + }; + const parseCsvFile = (file) => { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -468,6 +501,100 @@ const SocialMediaManagement = ({ onSnackbar }) => { ); }; + const handlePasteSelectSlackUsers = () => { + const rawTokens = parseSlackLookupInput(slackPasteInput); + + if (rawTokens.length === 0) { + onSnackbar?.('Paste at least one Slack email, @handle, real name, or Slack ID', 'warning'); + return; + } + + const lookup = new Map(); + + slackUsers + .filter(user => user.email) + .forEach((user) => { + const keys = [user.id, user.name, user.real_name, user.email] + .map(normalizeSlackLookupToken) + .filter(Boolean); + + [...new Set(keys)].forEach((key) => { + if (!lookup.has(key)) { + lookup.set(key, []); + } + + const matches = lookup.get(key); + if (!matches.find(match => match.id === user.id)) { + matches.push(user); + } + }); + }); + + const matchedIds = new Set(); + const unmatchedTokens = []; + const ambiguousTokens = []; + let alreadySelectedCount = 0; + + rawTokens.forEach((token) => { + const normalizedToken = normalizeSlackLookupToken(token); + if (!normalizedToken) { + return; + } + + const matches = lookup.get(normalizedToken) || []; + + if (matches.length === 0) { + unmatchedTokens.push(token); + return; + } + + if (matches.length > 1) { + ambiguousTokens.push(token); + return; + } + + const matchedUser = matches[0]; + if (selectedSlackUsers.has(matchedUser.id) || matchedIds.has(matchedUser.id)) { + alreadySelectedCount += 1; + } + + matchedIds.add(matchedUser.id); + }); + + if (matchedIds.size > 0) { + setSelectedSlackUsers((prev) => new Set([...prev, ...matchedIds])); + } + + const remainingTokens = [...ambiguousTokens, ...unmatchedTokens]; + setSlackPasteInput(remainingTokens.join('\n')); + setSlackPasteFeedback({ + requestedCount: rawTokens.length, + matchedCount: matchedIds.size, + alreadySelectedCount, + unmatchedTokens, + ambiguousTokens, + }); + + if (matchedIds.size === 0) { + onSnackbar?.('No Slack users matched the pasted list', 'warning'); + return; + } + + const messageParts = [`Matched ${matchedIds.size} Slack user${matchedIds.size === 1 ? '' : 's'}`]; + + if (alreadySelectedCount > 0) { + messageParts.push(`${alreadySelectedCount} already selected`); + } + if (ambiguousTokens.length > 0) { + messageParts.push(`${ambiguousTokens.length} ambiguous`); + } + if (unmatchedTokens.length > 0) { + messageParts.push(`${unmatchedTokens.length} not found`); + } + + onSnackbar?.(messageParts.join(' · '), unmatchedTokens.length > 0 || ambiguousTokens.length > 0 ? 'warning' : 'success'); + }; + const getSelectedUsers = () => { const selectedSlack = slackUsers .filter(user => selectedSlackUsers.has(user.id) && user.email) @@ -1226,6 +1353,78 @@ user@domain.org; admin@site.net" + + + Paste Slack users + + + Paste one item per line or comma-separated. Match by email, Slack @handle, + real name, or Slack user ID. + + + setSlackPasteInput(e.target.value)} + sx={{ flexGrow: 1, minWidth: 280 }} + /> + + + + + + + {slackPasteFeedback && ( + 0 || slackPasteFeedback.ambiguousTokens.length > 0 + ? 'warning' + : 'success' + } + sx={{ mt: 1.5 }} + > + + Matched {slackPasteFeedback.matchedCount} of{' '} + {slackPasteFeedback.requestedCount} pasted entries. + {slackPasteFeedback.alreadySelectedCount > 0 && ( + <> {slackPasteFeedback.alreadySelectedCount} were already selected. + )} + + {slackPasteFeedback.ambiguousTokens.length > 0 && ( + + Ambiguous: {slackPasteFeedback.ambiguousTokens.slice(0, 5).join(', ')} + {slackPasteFeedback.ambiguousTokens.length > 5 ? '…' : ''} + + )} + {slackPasteFeedback.unmatchedTokens.length > 0 && ( + + Not found: {slackPasteFeedback.unmatchedTokens.slice(0, 5).join(', ')} + {slackPasteFeedback.unmatchedTokens.length > 5 ? '…' : ''} + + )} + + )} + + {/* Users List */} {getFilteredSlackUsers().length === 0 ? ( diff --git a/src/components/admin/TeamAssignments.js b/src/components/admin/TeamAssignments.js index a764b0f0..27811571 100644 --- a/src/components/admin/TeamAssignments.js +++ b/src/components/admin/TeamAssignments.js @@ -66,7 +66,10 @@ const ASSIGNMENT_STATUS = { FINALIZING: 'finalizing' }; -const TeamAssignments = ({ orgId }) => { +// When `embeddedHackathonId` is provided (Firestore doc id), the component +// skips its own URL parsing/writeback and hides the event picker, pinning to +// that one hackathon. See TeamManagement for the same contract. +const TeamAssignments = ({ orgId, embeddedHackathonId }) => { const theme = useTheme(); const { accessToken } = useAuthInfo(); const { enqueueSnackbar } = useSnackbar(); @@ -94,12 +97,19 @@ const TeamAssignments = ({ orgId }) => { direction: "asc", }); - // Handle URL parameters and set initial state + // Handle URL parameters and set initial state. + // Embedded mode pins to the host page's hackathon and ignores URL state. useEffect(() => { + if (embeddedHackathonId) { + if (selectedHackathon !== embeddedHackathonId) { + setSelectedHackathon(embeddedHackathonId); + } + return; + } if (!router?.query) return; - + const { event_id, tab } = router.query; - + if (event_id && Array.isArray(hackathons) && hackathons.some(h => h?.id === event_id)) { setSelectedHackathon(event_id); } else if (Array.isArray(hackathons) && hackathons.length > 0 && !selectedHackathon) { @@ -111,34 +121,36 @@ const TeamAssignments = ({ orgId }) => { const dateB = new Date(b.start_date); return dateB - dateA; // Most recent first }); - + if (sortedHackathons.length > 0) { setSelectedHackathon(sortedHackathons[0].id); } } - + if (tab !== undefined) { const tabIndex = parseInt(tab, 10); if (tabIndex >= 0 && tabIndex <= 1) { setViewMode(tabIndex); } } - }, [hackathons, router?.query]); + }, [embeddedHackathonId, hackathons, router?.query, selectedHackathon]); - // Update URL when selectedHackathon or viewMode changes + // Update URL when selectedHackathon or viewMode changes. + // Embedded mode never writes back — host page owns the URL. useEffect(() => { + if (embeddedHackathonId) return; if (!router?.replace || !selectedHackathon || !Array.isArray(hackathons) || hackathons.length === 0) { return; } - + try { // Build query parameters const queryParams = new URLSearchParams(); queryParams.set('event_id', selectedHackathon); queryParams.set('tab', viewMode.toString()); - + const newUrl = `${router.pathname}?${queryParams.toString()}`; - + const newQuery = Object.fromEntries(queryParams); router.replace({ pathname: router.pathname, @@ -147,7 +159,7 @@ const TeamAssignments = ({ orgId }) => { } catch (error) { console.warn('Failed to update URL:', error); } - }, [selectedHackathon, viewMode, router, hackathons]); + }, [embeddedHackathonId, selectedHackathon, viewMode, router, hackathons]); // Fetch both nonprofits and teams when hackathon changes useEffect(() => { @@ -918,42 +930,46 @@ const TeamAssignments = ({ orgId }) => { return (
- - - Team Assignments - - - Manage assignments between teams and nonprofits. Ensure all nonprofits have the right teams to support their projects. - - + {!embeddedHackathonId && ( + + + Team Assignments + + + Manage assignments between teams and nonprofits. Ensure all nonprofits have the right teams to support their projects. + + + )} - - - - Select Hackathon - - - - - + {!embeddedHackathonId && ( + + + + Select Hackathon + + + + + )} + string|null (returns error msg or null) + * previewKind — "video" | "none" + * onSave(team, partial) — async; should perform the patch and resolve on success + */ +const TeamFieldPopover = ({ + open, + anchorEl, + onClose, + team, + field, + label, + placeholder, + helperText, + validate, + previewKind = "none", + onSave, +}) => { + const initial = team?.[field] || ""; + const [value, setValue] = useState(initial); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (open) { + setValue(team?.[field] || ""); + } + }, [open, team, field]); + + const validationError = validate ? validate(value) : null; + const trimmed = (value || "").trim(); + const isDirty = trimmed !== (team?.[field] || "").trim(); + const canSave = !saving && !validationError && isDirty; + + const handleSave = async () => { + if (!canSave) return; + setSaving(true); + try { + await onSave(team, { [field]: trimmed }); + onClose(); + } catch (err) { + // onSave is expected to surface its own error UI (snackbar); leave popover open. + console.error(`Failed to save ${field}:`, err); + } finally { + setSaving(false); + } + }; + + const handleClear = async () => { + if (saving) return; + setSaving(true); + try { + await onSave(team, { [field]: "" }); + onClose(); + } catch (err) { + console.error(`Failed to clear ${field}:`, err); + } finally { + setSaving(false); + } + }; + + return ( + + + + {team?.name ? `${team.name} — ${label}` : label} + + setValue(e.target.value)} + error={!!validationError} + helperText={validationError || helperText} + onKeyDown={(e) => { + if (e.key === "Enter" && canSave) { + e.preventDefault(); + handleSave(); + } + }} + sx={{ mb: 1.5 }} + /> + + {previewKind === "video" && trimmed && !validationError && ( + + + + )} + + + {team?.[field] && ( + + )} + + + + + + ); +}; + +export default TeamFieldPopover; diff --git a/src/components/admin/TeamManagement.js b/src/components/admin/TeamManagement.js index 35c5982f..c12d655a 100644 --- a/src/components/admin/TeamManagement.js +++ b/src/components/admin/TeamManagement.js @@ -1,5 +1,6 @@ import React, { useState, useEffect, useCallback } from 'react'; import { + Autocomplete, Box, Button, Card, @@ -66,7 +67,9 @@ import { FaExternalLinkAlt, FaCheckCircle, FaBug, - FaLink + FaLink, + FaVideo, + FaPlus } from 'react-icons/fa'; import axios from 'axios'; import { useAuthInfo } from '@propelauth/react'; @@ -74,7 +77,9 @@ import { useSnackbar } from 'notistack'; import { useRouter } from 'next/router'; import useHackathonEvents from '../../hooks/use-hackathon-events'; import UserSearchDialog from './UserSearchDialog'; -import { TEAM_STATUS_OPTIONS, getStatusOption } from '../../constants/teamStatus'; +import TeamFieldPopover from './TeamFieldPopover'; +import LiteVideoThumbnail from '../VideoDisplay/LiteVideoThumbnail'; +import { TEAM_STATUS_OPTIONS, getStatusOption, WINNING_STATUSES, isWinningStatus } from '../../constants/teamStatus'; // GitHub Issue Templates for different hackathon phases // Returns templates with event-specific URLs based on the provided eventId @@ -240,8 +245,12 @@ const MESSAGE_TEMPLATES = { } }; -// Component for managing teams in the admin panel -const TeamManagement = ({ orgId }) => { +// Component for managing teams in the admin panel. +// When `embeddedHackathonId` is provided (the Firestore doc id of a hackathon), +// the component skips its own URL parsing/writeback and hides the event picker, +// pinning the view to that one hackathon. Used by the per-event sidebar +// section at /admin/hackathons/[event_id]?section=teams. +const TeamManagement = ({ orgId, embeddedHackathonId }) => { const theme = useTheme(); const { accessToken } = useAuthInfo(); const { enqueueSnackbar } = useSnackbar(); @@ -251,6 +260,27 @@ const TeamManagement = ({ orgId }) => { const { hackathons = [] } = useHackathonEvents(false) || {}; const [selectedHackathon, setSelectedHackathon] = useState(''); + // Stable option list — prevents MUI Autocomplete from resetting inputValue on re-render + const hackathonOptions = React.useMemo( + () => + hackathons + .filter((h) => h?.id) + .sort((a, b) => { + const da = a.start_date ? new Date(a.start_date) : new Date(0); + const db = b.start_date ? new Date(b.start_date) : new Date(0); + return db - da; + }) + .map((h) => ({ + id: h.id, + label: `${h.event_id}${h.start_date ? ' \u00b7 ' + new Date(h.start_date).toLocaleDateString() : ''}`, + })), + [hackathons] + ); + const selectedHackathonOption = React.useMemo( + () => hackathonOptions.find((o) => o.id === selectedHackathon) ?? null, + [hackathonOptions, selectedHackathon] + ); + // State for teams data and UI const [loading, setLoading] = useState(false); const [teams, setTeams] = useState([]); @@ -293,12 +323,31 @@ const TeamManagement = ({ orgId }) => { const [expandedRepo, setExpandedRepo] = useState(null); const [githubIssueSummaries, setGithubIssueSummaries] = useState({}); // Add state for table issue summaries - // Handle URL parameters and set initial state + // Status / completeness filter applied above the team table + const [activeFilter, setActiveFilter] = useState('all'); + + // Inline quick-edit popover (for video and devpost columns) + const [popoverState, setPopoverState] = useState({ + open: false, + anchorEl: null, + team: null, + field: null, + }); + + // Handle URL parameters and set initial state. + // When embedded, the parent owns event selection — pin to that hackathon + // and skip both URL parsing and the most-recent fallback. useEffect(() => { + if (embeddedHackathonId) { + if (selectedHackathon !== embeddedHackathonId) { + setSelectedHackathon(embeddedHackathonId); + } + return; + } if (!router?.query) return; - + const { event_id } = router.query; - + if (event_id && Array.isArray(hackathons) && hackathons.some(h => h?.id === event_id)) { setSelectedHackathon(event_id); } else if (Array.isArray(hackathons) && hackathons.length > 0 && !selectedHackathon) { @@ -310,19 +359,22 @@ const TeamManagement = ({ orgId }) => { const dateB = new Date(b.start_date); return dateB - dateA; // Most recent first }); - + if (sortedHackathons.length > 0) { setSelectedHackathon(sortedHackathons[0].id); } } - }, [hackathons, router?.query]); + }, [embeddedHackathonId, hackathons, router?.query, selectedHackathon]); // Update URL when selectedHackathon changes useEffect(() => { + // Host page owns the URL when embedded — never write back, or we'd + // clobber the [event_id] path param and the ?section= query. + if (embeddedHackathonId) return; if (!router?.replace || !selectedHackathon || !Array.isArray(hackathons) || hackathons.length === 0) { return; } - + try { // Build query parameters const queryParams = new URLSearchParams(); @@ -343,7 +395,7 @@ const TeamManagement = ({ orgId }) => { } catch (error) { console.warn('Failed to update URL:', error); } - }, [selectedHackathon, router, hackathons]); + }, [embeddedHackathonId, selectedHackathon, router, hackathons]); // Fetch teams for the selected hackathon useEffect(() => { @@ -352,32 +404,42 @@ const TeamManagement = ({ orgId }) => { } }, [selectedHackathon]); - console.log("Team Data:", teamData); - // Filter teams based on search term + // Filter teams based on search term + active filter chip useEffect(() => { if (!teams) return; - const filtered = teams.filter( - (team) => { - const searchLower = searchTerm.toLowerCase(); - - // Check team name - const nameMatch = team.name?.toLowerCase().includes(searchLower); - - // Check slack channel - const slackMatch = team.slack_channel?.toLowerCase().includes(searchLower); - - // Check team members - with null-safe checks - const memberMatch = team.team_members?.some( - (member) => member?.name?.toLowerCase().includes(searchLower) - ); - - return nameMatch || slackMatch || memberMatch; + const searchLower = searchTerm.toLowerCase(); + const filtered = teams.filter((team) => { + // Search filter + const nameMatch = team.name?.toLowerCase().includes(searchLower); + const slackMatch = team.slack_channel?.toLowerCase().includes(searchLower); + const memberMatch = team.team_members?.some( + (member) => member?.name?.toLowerCase().includes(searchLower) + ); + const matchesSearch = !searchLower || nameMatch || slackMatch || memberMatch; + if (!matchesSearch) return false; + + // Chip filter + switch (activeFilter) { + case 'winning': + return isWinningStatus(team.status); + case 'in_review': + return (team.status || 'IN_REVIEW') === 'IN_REVIEW'; + case 'active': + return team.active === 'True' || team.active === true; + case 'missing_devpost': + return !team.devpost_link; + case 'missing_video': + return !team.demo_video_url; + case 'all': + default: + return true; } - ); + }); setFilteredTeams(filtered); - }, [searchTerm, teams]); + setPage(0); + }, [searchTerm, teams, activeFilter]); // Sort teams when sortConfig changes useEffect(() => { @@ -396,17 +458,12 @@ const TeamManagement = ({ orgId }) => { setFilteredTeams(sortedTeams); }, [sortConfig]); - // Add function to fetch issue summary for table display - const fetchGithubIssueSummary = async (repo) => { + // Fetch issue summary for one repo. Returns [repoKey, summary] or null. + // NOTE: Intentionally does NOT setState — callers should batch the results + // into a single setGithubIssueSummaries call to avoid N-renders-per-fetch. + const fetchGithubIssueSummaryRaw = async (repo) => { const repoKey = `${repo.link}-summary`; - - // Don't refetch if we already have the data - if (githubIssueSummaries[repoKey]) { - return githubIssueSummaries[repoKey]; - } - try { - // Extract org and repo from the GitHub URL const urlParts = repo.link.split('/'); const org = urlParts[urlParts.length - 2]; const repoName = urlParts[urlParts.length - 1]; @@ -414,11 +471,7 @@ const TeamManagement = ({ orgId }) => { const response = await axios.get( `${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/github/issues`, { - params: { - org: org, - repo: repoName, - state: 'all' - }, + params: { org, repo: repoName, state: 'all' }, headers: { Authorization: `Bearer ${accessToken}`, "X-Org-Id": orgId, @@ -428,21 +481,14 @@ const TeamManagement = ({ orgId }) => { if (response.data && response.data.success) { const issues = response.data.issues || []; - const openCount = issues.filter(issue => issue.state === 'open').length; - const closedCount = issues.filter(issue => issue.state === 'closed').length; - const summary = { open: openCount, closed: closedCount, total: issues.length }; - - setGithubIssueSummaries(prev => ({ - ...prev, - [repoKey]: summary - })); - - return summary; + const openCount = issues.filter((issue) => issue.state === 'open').length; + const closedCount = issues.filter((issue) => issue.state === 'closed').length; + return [repoKey, { open: openCount, closed: closedCount, total: issues.length }]; } } catch (error) { console.error("Error fetching GitHub issues summary:", error); - return { open: 0, closed: 0, total: 0 }; } + return null; }; // Fetch teams for a hackathon @@ -462,18 +508,40 @@ const TeamManagement = ({ orgId }) => { if (response.data && response.data.teams) { setTeams(response.data.teams); setFilteredTeams(response.data.teams); - + // Fetch nonprofits to build the map for displaying nonprofit names fetchNonprofits(hackathonId); - - // Fetch GitHub issue summaries for all teams with repositories - response.data.teams.forEach(team => { - if (team.github_links && team.github_links.length > 0) { - team.github_links.forEach(repo => { - fetchGithubIssueSummary(repo); - }); - } + + // Fetch GitHub issue summaries for all teams with repositories. + // Dedupe by repo link, skip already-cached, and batch into a SINGLE + // setGithubIssueSummaries call so we don't trigger one re-render per + // repo (which used to cascade through this 2900-line component). + const reposToFetch = []; + const seen = new Set(); + response.data.teams.forEach((team) => { + (team.github_links || []).forEach((repo) => { + if (!repo?.link) return; + const repoKey = `${repo.link}-summary`; + if (seen.has(repoKey) || githubIssueSummaries[repoKey]) return; + seen.add(repoKey); + reposToFetch.push(repo); + }); }); + + if (reposToFetch.length > 0) { + (async () => { + const results = await Promise.all( + reposToFetch.map((repo) => fetchGithubIssueSummaryRaw(repo)) + ); + const next = {}; + results.forEach((entry) => { + if (entry) next[entry[0]] = entry[1]; + }); + if (Object.keys(next).length > 0) { + setGithubIssueSummaries((prev) => ({ ...prev, ...next })); + } + })(); + } } } catch (error) { console.error("Error fetching teams:", error); @@ -483,8 +551,38 @@ const TeamManagement = ({ orgId }) => { } }; - // Fetch nonprofits for the selected hackathon + // Track whether the current nonprofit list is the global fallback (hackathon + // had no nonprofits attached) vs. the hackathon-scoped list. + const [nonprofitSource, setNonprofitSource] = useState("hackathon"); + + // Fetch nonprofits for the selected hackathon. If the hackathon doc has no + // nonprofits attached (or the fetch fails), fall back to ALL nonprofits so + // an admin can still manually assign one to a team. const fetchNonprofits = async (hackathonId) => { + const applyList = (list, source) => { + setNonprofitOptions(list); + const npMap = {}; + list.forEach((np) => { + npMap[np.id] = np.name; + }); + setNonprofitMap(npMap); + setNonprofitSource(source); + }; + + const fetchAll = async () => { + const all = await axios.get( + `${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/messages/npos`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "X-Org-Id": orgId, + }, + } + ); + const list = all?.data?.nonprofits || []; + applyList(list, "all"); + }; + try { const response = await axios.get( `${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/messages/npos/hackathon/${hackathonId}`, @@ -496,19 +594,22 @@ const TeamManagement = ({ orgId }) => { } ); - if (response.data && response.data.nonprofits) { - setNonprofitOptions(response.data.nonprofits); - - // Build the nonprofit map for quick lookups - const npMap = {}; - response.data.nonprofits.forEach(nonprofit => { - npMap[nonprofit.id] = nonprofit.name; - }); - setNonprofitMap(npMap); + const scoped = response?.data?.nonprofits || []; + if (scoped.length > 0) { + applyList(scoped, "hackathon"); + } else { + // No nonprofits attached to this hackathon — fall back so the manual + // assignment dropdown still has something to choose from. + await fetchAll(); } } catch (error) { console.error("Error fetching nonprofits:", error); - enqueueSnackbar("Failed to fetch nonprofits", { variant: "error" }); + try { + await fetchAll(); + } catch (fallbackError) { + console.error("Error fetching all nonprofits fallback:", fallbackError); + enqueueSnackbar("Failed to fetch nonprofits", { variant: "error" }); + } } }; @@ -525,7 +626,6 @@ const TeamManagement = ({ orgId }) => { }, } ); - console.log("Team Details Response:", response.data); if (response.data && response.data.team) { // Ensure team_members is always an array to prevent rendering issues const team = { @@ -560,13 +660,50 @@ const TeamManagement = ({ orgId }) => { ...team, active: team.active === "True", }); - // Fetch GitHub issues for all repositories to populate summaries - console.log("Fetching GitHub issues for team:", team); - if (team.github_links && team.github_links.length > 0) { - team.github_links.forEach((repo) => { - fetchGithubIssues(repo, "all"); // Fetch all issues for summary - }); + + // Prefetch GitHub issues for all repos but BATCH the results into a single + // setGithubIssues call. Calling fetchGithubIssues in a forEach would + // setState once per repo, triggering N renders of the full edit dialog. + const reposToFetch = (team.github_links || []).filter( + (repo) => repo?.link && !githubIssues[`${repo.link}-all`] + ); + if (reposToFetch.length > 0) { + (async () => { + const results = await Promise.all( + reposToFetch.map(async (repo) => { + try { + const urlParts = repo.link.split('/'); + const org = urlParts[urlParts.length - 2]; + const repoName = urlParts[urlParts.length - 1]; + const response = await axios.get( + `${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/github/issues`, + { + params: { org, repo: repoName, state: 'all' }, + headers: { + Authorization: `Bearer ${accessToken}`, + "X-Org-Id": orgId, + }, + } + ); + if (response.data?.success) { + return [`${repo.link}-all`, response.data.issues || []]; + } + } catch (error) { + console.error("Error prefetching GitHub issues:", error); + } + return null; + }) + ); + const next = {}; + results.forEach((entry) => { + if (entry) next[entry[0]] = entry[1]; + }); + if (Object.keys(next).length > 0) { + setGithubIssues((prev) => ({ ...prev, ...next })); + } + })(); } + fetchNonprofits(selectedHackathon); // Reset message dialog state when switching teams setSelectedTemplate(null); @@ -583,29 +720,38 @@ const TeamManagement = ({ orgId }) => { })); }; - // Save team updates + // Low-level PATCH helper used by both the full edit Dialog save and inline Popover quick-edits. + // `partial` should include `id` and whatever fields to update. Caller is responsible for + // showing snackbars and refreshing list state if it cares about them. + const patchTeam = useCallback(async (partial) => { + const response = await axios.patch( + `${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/team/edit`, + partial, + { + headers: { + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + "X-Org-Id": orgId, + }, + } + ); + if (!response.data?.success) { + throw new Error(response.data?.message || "Update failed"); + } + return response.data; + }, [accessToken, orgId]); + + // Save team updates from the full edit Dialog const handleSaveTeam = async () => { setLoading(true); try { - const response = await axios.patch( - `${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/team/edit`, - { - ...teamData, - active: teamData.active ? "True" : "False", - }, - { - headers: { - authorization: `Bearer ${accessToken}`, - "content-type": "application/json", - "X-Org-Id": orgId, - }, - }); - - if (response.data && response.data.success) { - enqueueSnackbar("Team updated successfully", { variant: "success" }); - setEditDialogOpen(false); - fetchTeams(selectedHackathon); - } + await patchTeam({ + ...teamData, + active: teamData.active ? "True" : "False", + }); + enqueueSnackbar("Team updated successfully", { variant: "success" }); + setEditDialogOpen(false); + fetchTeams(selectedHackathon); } catch (error) { console.error("Error updating team:", error); enqueueSnackbar("Failed to update team", { variant: "error" }); @@ -614,6 +760,49 @@ const TeamManagement = ({ orgId }) => { } }; + // Optimistic inline-edit save used by TeamFieldPopover. + // Updates the local teams list immediately so the table reflects the change without a full refetch. + const handleQuickPatch = useCallback(async (team, partial) => { + try { + await patchTeam({ id: team.id, ...partial }); + setTeams((prev) => + prev.map((t) => (t.id === team.id ? { ...t, ...partial } : t)) + ); + enqueueSnackbar("Team updated", { variant: "success" }); + } catch (error) { + console.error("Quick-edit failed:", error); + enqueueSnackbar("Failed to update team", { variant: "error" }); + throw error; + } + }, [patchTeam, enqueueSnackbar]); + + // Demo-video URL validator shared by the Popover and the full edit Dialog + const validateDemoVideoUrl = (value) => { + if (!value) return null; // empty = clear (allowed) + const trimmed = value.trim(); + if (trimmed.length > 500) return "URL too long (500 char max)."; + const valid = + /youtube\.com\/.+v=[\w-]{11}/i.test(trimmed) || + /youtu\.be\/[\w-]{11}/i.test(trimmed) || + /vimeo\.com\/\d+/i.test(trimmed) || + /loom\.com\/(share|embed)\/[a-zA-Z0-9]+/i.test(trimmed) || + /drive\.google\.com\/file\/d\//i.test(trimmed); + return valid ? null : "Enter a YouTube, Vimeo, Loom, or Google Drive URL."; + }; + + const openVideoPopover = (event, team) => { + setPopoverState({ + open: true, + anchorEl: event.currentTarget, + team, + field: "demo_video_url", + }); + }; + + const closePopover = () => { + setPopoverState((prev) => ({ ...prev, open: false })); + }; + // Send message to team's slack channel const handleSendMessage = async () => { setLoading(true); @@ -723,7 +912,6 @@ const TeamManagement = ({ orgId }) => { // Handle user selection from user search dialog const handleUserSelect = (user) => { if (user && user.id) { - console.log("User added to team:", user); // Reload team details to reflect the new member loadTeamDetails(teamData.id); // Show success message @@ -1028,16 +1216,48 @@ const TeamManagement = ({ orgId }) => { // Render selected nonprofit with history const renderNonprofitSelection = () => { - if (!teamData || !teamData.nonprofit_rankings) return null; + if (!teamData) return null; + + const rankings = Array.isArray(teamData.nonprofit_rankings) + ? teamData.nonprofit_rankings + : []; + const hasRankings = rankings.length > 0; + const hasOptions = nonprofitOptions.length > 0; return ( + {!hasRankings && ( + + This team has no nonprofit rankings on file — the team + creation / nonprofit matching flow was not completed for this + hackathon. You can still manually assign a nonprofit below. + + )} + {!hasOptions && ( + + No nonprofits are attached to this hackathon, and the global + fallback returned no nonprofits either. Add nonprofits to the + hackathon (Admin → Hackathons → this event → Nonprofits) and + reopen this dialog. + + )} + {hasOptions && nonprofitSource === "all" && ( + + Showing all nonprofits as a fallback because none are attached + to this hackathon. + + )} + Current Assignment @@ -1053,6 +1273,7 @@ const TeamManagement = ({ orgId }) => { handleTeamDataChange("selected_nonprofit_id", e.target.value) } label="Assigned Nonprofit" + disabled={!hasOptions} > Not assigned yet @@ -1066,54 +1287,118 @@ const TeamManagement = ({ orgId }) => { - - Team's Nonprofit Rankings - - - - - - Rank - Nonprofit - Status - - - - {teamData.nonprofit_rankings.map((ranking, index) => { - const nonprofit = nonprofitOptions.find( - (n) => n.id === ranking.nonprofit_id - ); - const isSelected = - teamData.selected_nonprofit_id === ranking.nonprofit_id; - - return ( - - {ranking.rank} - - {nonprofit ? nonprofit.name : "Unknown Nonprofit"} - - - {isSelected ? ( - } - /> - ) : ( - - )} - + {(teamData.status || "IN_REVIEW") === "IN_REVIEW" ? ( + + + + {teamData.selected_nonprofit_id + ? "Ready to approve" + : "Pick a nonprofit to approve this team"} + + + {teamData.selected_nonprofit_id + ? `Approving notifies the team and assigns them to ${getNonprofitName( + teamData.selected_nonprofit_id + )}.` + : "Choose a nonprofit above, then approve — this notifies the team and sets their status to Nonprofit Selected."} + + + + + + + + + ) : ( + } sx={{ mb: 3 }}> + This team has been approved + {teamData.selected_nonprofit_id + ? ` and assigned to ${getNonprofitName( + teamData.selected_nonprofit_id + )}` + : ""} + . + + )} + + {hasRankings && ( + <> + + Team's Nonprofit Rankings + + +
+ + + Rank + Nonprofit + Status - ); - })} - -
-
+ + + {rankings.map((ranking, index) => { + const nonprofit = nonprofitOptions.find( + (n) => n.id === ranking.nonprofit_id + ); + const isSelected = + teamData.selected_nonprofit_id === ranking.nonprofit_id; + + return ( + + {ranking.rank} + + {nonprofit ? nonprofit.name : "Unknown Nonprofit"} + + + {isSelected ? ( + } + /> + ) : ( + + )} + + + ); + })} + + + + + )}
); @@ -1415,6 +1700,53 @@ const TeamManagement = ({ orgId }) => { }} /> + + handleTeamDataChange("demo_video_url", e.target.value) + } + placeholder="https://youtu.be/..." + error={!!validateDemoVideoUrl(teamData.demo_video_url)} + helperText={ + validateDemoVideoUrl(teamData.demo_video_url) || + "YouTube, Vimeo, Loom, or Google Drive" + } + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: teamData.demo_video_url && ( + + + + + + ), + }} + /> + {teamData.demo_video_url && !validateDemoVideoUrl(teamData.demo_video_url) && ( + + + window.open(teamData.demo_video_url, '_blank', 'noopener,noreferrer') + } + /> + + )} + { > Send Message - - {/* Add Approve Team Button */} - @@ -1963,43 +2280,36 @@ const TeamManagement = ({ orgId }) => { return (
- - - Team Management - - - Manage teams, assign nonprofits, and monitor team progress across all - hackathons. - - + {!embeddedHackathonId && ( + + + Team Management + + + Manage teams, assign nonprofits, and monitor team progress across all + hackathons. + + + )} - - - - Select Hackathon - - - - - + {!embeddedHackathonId && ( + + setSelectedHackathon(option?.id ?? '')} + isOptionEqualToValue={(opt, val) => opt.id === val.id} + renderInput={(params) => ( + + )} + noOptionsText="No hackathons found" + /> + + )} + { /> + + {/* Filter chips — quick scoping for demo/judging-day workflows */} + {selectedHackathon && ( + + + Show: + + {[ + { key: 'all', label: 'All' }, + { key: 'winning', label: 'Winning' }, + { key: 'in_review', label: 'In review' }, + { key: 'active', label: 'Active' }, + { key: 'missing_devpost', label: 'Missing DevPost' }, + { key: 'missing_video', label: 'Missing Video' }, + ].map((f) => { + const selected = activeFilter === f.key; + return ( + setActiveFilter(f.key)} + /> + ); + })} + + )} {loading && !editDialogOpen ? ( @@ -2118,6 +2458,7 @@ const TeamManagement = ({ orgId }) => { Members GitHub DevPost + Demo Video Nonprofit Actions @@ -2217,6 +2558,28 @@ const TeamManagement = ({ orgId }) => { )} + + {team.demo_video_url ? ( + + openVideoPopover(e, team)} + /> + + ) : ( + + )} + {getNonprofitName(team.selected_nonprofit_id)} @@ -2299,6 +2662,30 @@ const TeamManagement = ({ orgId }) => { + + {(teamData?.status || "IN_REVIEW") === "IN_REVIEW" && + (teamData?.selected_nonprofit_id ? ( + + ) : ( + + + + ))}
); }; diff --git a/src/components/admin/VolunteerCommunication.js b/src/components/admin/VolunteerCommunication.js index cf3b77f7..0f12759f 100644 --- a/src/components/admin/VolunteerCommunication.js +++ b/src/components/admin/VolunteerCommunication.js @@ -26,24 +26,24 @@ import { import { useSnackbar } from 'notistack'; import axios from 'axios'; import { - MESSAGE_TEMPLATES, filterTemplatesByType, prepareTemplateMessage, detectPlaceholders, PLACEHOLDER_LABELS } from '../../lib/messageTemplates'; import useSavedPlaceholders from '../../hooks/use-saved-placeholders'; +import useEmailTemplates from '../../hooks/use-email-templates'; -const VolunteerCommunication = ({ - volunteer, +const VolunteerCommunication = ({ + volunteer, volunteerType, // Add this prop - onMessageSent, - eventId, - orgId, + onMessageSent, + eventId, + orgId, accessToken, open, - onClose + onClose }) => { const { enqueueSnackbar } = useSnackbar(); const [messageDialogOpen, setMessageDialogOpen] = useState(open || false); @@ -58,6 +58,14 @@ const VolunteerCommunication = ({ const { savedValues, saveValues, clearValues, hasSavedValues } = useSavedPlaceholders(eventId, selectedTemplate?.id); + // Admin-managed templates from the backend (falls back to the hardcoded + // set while loading / on error). Only fetched once the dialog is open. + const { templates } = useEmailTemplates({ + accessToken, + orgId, + enabled: messageDialogOpen, + }); + const handleSendMessage = async () => { if (!messageText.trim() || !volunteer) return; @@ -252,240 +260,243 @@ const VolunteerCommunication = ({ // Get filtered templates based on volunteer type using shared utility const volType = volunteerType || volunteer?.type; - const filteredTemplates = filterTemplatesByType(volType); - - // If used with external dialog control, skip the card and just show dialog - if (open !== undefined) { - return ( - /* Send Message Dialog */ - - - - - Send Message to {volunteer.name || 'Volunteer'} ({volunteerType || volunteer.type}) - - - - - {!customMessage && !selectedTemplate && ( - - - Choose a Message Template for {volunteerType || volunteer.type} - - - Templates filtered for {volunteerType || volunteer.type} role only - - - {Object.entries(filteredTemplates).map(([categoryKey, category]) => ( - - - {category.category} - - - {category.templates.map((template) => ( - - handleTemplateSelect(template)} - > - - - - {template.icon} - - - {template.title} - - - - {template.message} + const filteredTemplates = filterTemplatesByType(volType, templates); + + // Single dialog rendered for both usages (external control and the + // self-contained card). Previously this JSX was duplicated wholesale. + const messageDialog = ( + + + + + Send Message to {volunteer.name || 'Volunteer'} ({volunteerType || volunteer.type}) + + + + + {!customMessage && !selectedTemplate && ( + + + Choose a Message Template for {volunteerType || volunteer.type} + + + Templates filtered for {volunteerType || volunteer.type} role only + + + {Object.entries(filteredTemplates).map(([categoryKey, category]) => ( + + + {category.category} + + + {category.templates.map((template) => ( + + handleTemplateSelect(template)} + > + + + + {template.icon} - - - - ))} - - - ))} - - {Object.keys(filteredTemplates).length === 0 && ( - - - No specific templates available for {volunteerType || volunteer.type}. - - - You can still write a custom message. - - - )} - - - + + {template.title} + + + + {template.message} + + + + + ))} + - - )} - - {(selectedTemplate || customMessage) && ( - - - - {selectedTemplate ? ( - - {selectedTemplate.icon} - {selectedTemplate.title} - - ) : ( - 'Custom Message' - )} + ))} + + {Object.keys(filteredTemplates).length === 0 && ( + + + No specific templates available for {volunteerType || volunteer.type}. + + + You can still write a custom message. - - - {selectedTemplate && detectedPlaceholders.length > 0 && ( - - - - - This template requires event-specific details: - - {restoredFromSaved && ( - - )} - - {hasSavedValues && ( - - )} - - - {detectedPlaceholders.map((name) => { - const info = PLACEHOLDER_LABELS[name] || { label: name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()), example: '' }; - return ( - - handlePlaceholderChange(name, e.target.value)} - helperText={info.example} - variant="outlined" - /> - - ); - })} - - - )} + )} - + - )} - - - - + + )} + {(selectedTemplate || customMessage) && ( - <> - + + + {selectedTemplate && detectedPlaceholders.length > 0 && ( + + + + + This template requires event-specific details: + + {restoredFromSaved && ( + + )} + + {hasSavedValues && ( + + )} + + + {detectedPlaceholders.map((name) => { + const info = PLACEHOLDER_LABELS[name] || { label: name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()), example: '' }; + return ( + + handlePlaceholderChange(name, e.target.value)} + helperText={info.example} + variant="outlined" + /> + + ); + })} + + + )} + + setMessageText(e.target.value)} + placeholder="Type your message to the volunteer..." variant="outlined" - color="secondary" - disabled={testLoading || loading || !messageText.trim()} - startIcon={testLoading ? : } - > - Send Test to questions@ohack.org - - - + helperText={`Message will be sent to ${volunteer.name || 'volunteer'} at ${volunteer.email || 'their registered email'}`} + /> + + {selectedTemplate && ( + + + 💡 Feel free to customize this template before sending. The message above can be edited to fit your specific needs. + + + )} + )} - - - ); + + + + + {(selectedTemplate || customMessage) && ( + <> + + + + )} + + + ); + + // If used with external dialog control, skip the card and just show dialog + if (open !== undefined) { + return messageDialog; } return ( @@ -518,236 +529,9 @@ const VolunteerCommunication = ({ - {/* Send Message Dialog */} - - - - - Send Message to {volunteer.name || 'Volunteer'} ({volunteerType || volunteer.type}) - - - - - {!customMessage && !selectedTemplate && ( - - - Choose a Message Template for {volunteerType || volunteer.type} - - - Templates filtered for {volunteerType || volunteer.type} role only - - - {Object.entries(filteredTemplates).map(([categoryKey, category]) => ( - - - {category.category} - - - {category.templates.map((template) => ( - - handleTemplateSelect(template)} - > - - - - {template.icon} - - - {template.title} - - - - {template.message} - - - - - ))} - - - ))} - - {Object.keys(filteredTemplates).length === 0 && ( - - - No specific templates available for {volunteerType || volunteer.type}. - - - You can still write a custom message. - - - )} - - - - - - )} - - {(selectedTemplate || customMessage) && ( - - - - {selectedTemplate ? ( - - {selectedTemplate.icon} - {selectedTemplate.title} - - ) : ( - 'Custom Message' - )} - - - - - {selectedTemplate && detectedPlaceholders.length > 0 && ( - - - - - This template requires event-specific details: - - {restoredFromSaved && ( - - )} - - {hasSavedValues && ( - - )} - - - {detectedPlaceholders.map((name) => { - const info = PLACEHOLDER_LABELS[name] || { label: name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()), example: '' }; - return ( - - handlePlaceholderChange(name, e.target.value)} - helperText={info.example} - variant="outlined" - /> - - ); - })} - - - )} - - setMessageText(e.target.value)} - placeholder="Type your message to the volunteer..." - variant="outlined" - helperText={`Message will be sent to ${volunteer.name || 'volunteer'} at ${volunteer.email || 'their registered email'}`} - /> - - {selectedTemplate && ( - - - 💡 Feel free to customize this template before sending. The message above can be edited to fit your specific needs. - - - )} - - )} - - - - - {(selectedTemplate || customMessage) && ( - <> - - - - )} - - + {messageDialog} ); }; -export default VolunteerCommunication; \ No newline at end of file +export default VolunteerCommunication; diff --git a/src/components/admin/VolunteerTable.js b/src/components/admin/VolunteerTable.js index 19df394f..d9555919 100644 --- a/src/components/admin/VolunteerTable.js +++ b/src/components/admin/VolunteerTable.js @@ -39,8 +39,10 @@ import { styled } from "@mui/system"; import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import CancelIcon from "@mui/icons-material/Cancel"; import EditIcon from "@mui/icons-material/Edit"; -import { Email as EmailIcon, VolunteerActivism as CertificateIcon } from '@mui/icons-material'; +import { Email as EmailIcon, VolunteerActivism as CertificateIcon, OpenInNew as OpenInNewIcon } from '@mui/icons-material'; import { FaPaperPlane, FaSlack, FaLinkedin } from 'react-icons/fa'; +import NextLink from 'next/link'; +import HackerDepositChip from "./HackerDepositChip"; const StyledTableContainer = styled(TableContainer)(({ theme }) => ({ width: "100%", @@ -201,12 +203,17 @@ const VolunteerTable = ({ // Auth props for Resend status lookup accessToken, orgId, + // Deposit refund (hackers only) + depositEnabled = false, + onDepositClick, }) => { const [copyFeedback, setCopyFeedback] = useState({ open: false, message: '' }); const [resendStatuses, setResendStatuses] = useState({}); // { resend_id: { last_event, ... } } const [loadingResendStatus, setLoadingResendStatus] = useState({}); const [resendEmailsByRecipient, setResendEmailsByRecipient] = useState({}); // { email: [{id, subject, created_at, last_event}] } const [resendListLoaded, setResendListLoaded] = useState(false); + const [resendSyncing, setResendSyncing] = useState(false); + const [resendSyncSnackbar, setResendSyncSnackbar] = useState(false); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); const isSmallMobile = useMediaQuery(theme.breakpoints.down('sm')); @@ -288,7 +295,7 @@ const VolunteerTable = ({ }, [accessToken, orgId, resendStatuses, loadingResendStatus]); // Fetch all sent emails from Resend list API, indexed by recipient - const fetchResendEmailList = useCallback(async () => { + const fetchResendEmailList = useCallback(async (force = false) => { if (!accessToken || !orgId || !volunteers?.length) return; const uniqueEmails = [ @@ -302,6 +309,7 @@ const VolunteerTable = ({ if (uniqueEmails.length === 0) return; try { + setResendSyncing(true); const response = await fetch( `${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/admin/emails/resend-list`, { @@ -311,14 +319,14 @@ const VolunteerTable = ({ 'Content-Type': 'application/json', 'X-Org-Id': orgId, }, - body: JSON.stringify({ emails: uniqueEmails }), + body: JSON.stringify({ emails: uniqueEmails, force }), } ); if (response.ok) { const data = await response.json(); - const emailsByRecipient = - data?.data?.emails_by_recipient || data?.emails_by_recipient; + const payload = data?.data || data; + const emailsByRecipient = payload?.emails_by_recipient; if (emailsByRecipient) { const normalizedEmailsByRecipient = {}; Object.entries(emailsByRecipient).forEach(([key, value]) => { @@ -330,11 +338,17 @@ const VolunteerTable = ({ }); setResendEmailsByRecipient(normalizedEmailsByRecipient); } - // Mark list as successfully loaded only after a successful fetch setResendListLoaded(true); + if (payload?.syncing) { + setResendSyncSnackbar(true); + // Re-fetch once after ~30s to pick up background sync results + setTimeout(() => fetchResendEmailList(false), 30000); + } } } catch (err) { console.warn("Failed to fetch Resend email list:", err); + } finally { + setResendSyncing(false); } }, [accessToken, orgId, volunteers]); @@ -344,12 +358,21 @@ const VolunteerTable = ({ setResendEmailsByRecipient({}); }, [orgId, accessToken]); - // Fetch Resend email list once when volunteers load + // Bulk-fetch delivery status for all known resend_ids when volunteers load (DB-first, no list crawl) useEffect(() => { - if (!resendListLoaded && volunteers?.length > 0 && accessToken && orgId) { - fetchResendEmailList(); + if (!volunteers?.length || !accessToken || !orgId) return; + const allIds = []; + volunteers.forEach(v => { + const emails = Array.isArray(v.sent_emails) ? v.sent_emails : []; + emails.forEach(e => { if (e.resend_id && !resendStatuses[e.resend_id]) allIds.push(e.resend_id); }); + }); + if (allIds.length === 0) return; + const unique = [...new Set(allIds)]; + for (let i = 0; i < unique.length; i += 100) { + fetchResendStatuses(unique.slice(i, i + 100)); } - }, [volunteers, accessToken, orgId, resendListLoaded, fetchResendEmailList]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [volunteers, accessToken, orgId]); // Helper to get sent emails from either new sent_emails or legacy messages_sent const getSentEmails = useCallback((volunteer) => { @@ -428,15 +451,19 @@ const VolunteerTable = ({ { id: "artifacts", label: "Contrib.", minWidth: 100, priority: 3 }, ]; } else if (type === "hackers") { - return [ + const cols = [ ...baseColumns, { id: "checkedIn", label: "Checked In", minWidth: 80, priority: 2 }, - { id: "teamCode", label: "Team Code", minWidth: 50 }, // Reduced from 120, shorter label + { id: "teamCode", label: "Team Code", minWidth: 50 }, // Reduced from 120, shorter label { id: "participantType", label: "Type", minWidth: 80 }, // Reduced from 120, shorter label { id: "experienceLevel", label: "Exp.", minWidth: 60 }, // Reduced from 120, shorter label { id: "teamStatus", label: "Team", minWidth: 80 }, // Reduced from 120, shorter label { id: "primaryRoles", label: "Roles", minWidth: 100, priority: 2 }, // Reduced from 150 ]; + if (depositEnabled) { + cols.push({ id: "deposit", label: "Deposit", minWidth: 130 }); + } + return cols; } else if (type === "sponsors") { return [ ...baseColumns, @@ -453,7 +480,7 @@ const VolunteerTable = ({ } return baseColumns; - }, [type]); + }, [type, depositEnabled]); const selectedCount = useMemo(() => { return volunteers.filter((volunteer) => volunteer.isSelected).length; @@ -507,6 +534,13 @@ const VolunteerTable = ({ const renderCellContent = (volunteer, column) => { switch (column.id) { + case "deposit": + return ( + onDepositClick(volunteer) : undefined} + /> + ); case "id": const id = volunteer.id || "N/A"; const truncatedId = id.length > 8 ? `${id.slice(0, 8)}...` : id; @@ -1498,7 +1532,7 @@ const VolunteerTable = ({ {/* Header Row */} @@ -1571,6 +1605,16 @@ const VolunteerTable = ({ color="primary" /> )} + {type === "hackers" && depositEnabled && ( + onDepositClick(volunteer) + : undefined + } + /> + )} {/* Key Info for Specific Types */} @@ -1614,7 +1658,7 @@ const VolunteerTable = ({ {/* Actions */} - + onEditVolunteer(volunteer)} @@ -1635,7 +1679,20 @@ const VolunteerTable = ({ )} - {/* Spacer */} + {volunteer.user_db_id && ( + + + + + + )} + {volunteer.email && ( + + + setResendSyncSnackbar(false)} + message="Sync started — refreshing in ~30s" + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + /> + @@ -1884,7 +1963,7 @@ const VolunteerTable = ({ - onEditVolunteer(volunteer)} size="small" > @@ -1893,7 +1972,7 @@ const VolunteerTable = ({ {onMessageVolunteer && ( - onMessageVolunteer(volunteer)} size="small" color="primary" @@ -1902,6 +1981,20 @@ const VolunteerTable = ({ )} + {volunteer.user_db_id && ( + + + + + + )} {columns.map((column) => { diff --git a/src/components/admin/blog-edit/BlogAdminLayout.js b/src/components/admin/blog-edit/BlogAdminLayout.js new file mode 100644 index 00000000..19916541 --- /dev/null +++ b/src/components/admin/blog-edit/BlogAdminLayout.js @@ -0,0 +1,202 @@ +import React from "react"; +import { + Box, + Button, + Chip, + IconButton, + List, + ListItemButton, + ListItemIcon, + ListItemText, + Stack, + Tooltip, + Typography, + Drawer, + useMediaQuery, +} from "@mui/material"; +import { + ArrowBack as BackIcon, + Launch as LaunchIcon, + Menu as MenuIcon, + FiberManualRecord as DotIcon, +} from "@mui/icons-material"; +import { useTheme } from "@mui/material/styles"; +import { SECTIONS } from "./sectionsManifest"; +import SaveIndicator from "./SaveIndicator"; + +const SIDEBAR_WIDTH = 220; + +const statusChipColor = (status) => { + if (status === "draft") return "warning"; + if (status === "archived") return "default"; + return "success"; +}; + +const SidebarContent = ({ activeSection, onPick, dirtySections }) => ( + + + Sections + + + {SECTIONS.map(({ slug, label, icon: Icon }) => { + const active = activeSection === slug; + const dirty = dirtySections?.has(slug); + return ( + onPick(slug)} + sx={{ + mx: 1, + borderRadius: 1, + my: 0.25, + "&.Mui-selected": { + bgcolor: "primary.50", + color: "primary.main", + "& .MuiListItemIcon-root": { color: "primary.main" }, + }, + }} + > + + + + + {dirty && ( + + + + )} + + ); + })} + + +); + +const BlogAdminLayout = ({ + post, + activeSection, + onSelectSection, + saveState, + dirtySections, + onBack, + children, +}) => { + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("md")); + const [drawerOpen, setDrawerOpen] = React.useState(false); + + const postId = post?.id; + const title = post?.title || "Untitled post"; + const status = post?.status || "published"; + + return ( + + {isSmall ? ( + setDrawerOpen(false)}> + { + onSelectSection(s); + setDrawerOpen(false); + }} + dirtySections={dirtySections} + /> + + ) : ( + + + + )} + + + + {isSmall && ( + setDrawerOpen(true)} aria-label="Open sections"> + + + )} + + + + + + + + {title} + + + + + + {postId && ( + + )} + + + + {children} + + + ); +}; + +export default BlogAdminLayout; diff --git a/src/components/admin/blog-edit/SaveIndicator.js b/src/components/admin/blog-edit/SaveIndicator.js new file mode 100644 index 00000000..034038a9 --- /dev/null +++ b/src/components/admin/blog-edit/SaveIndicator.js @@ -0,0 +1,58 @@ +import React, { useEffect, useState } from "react"; +import { Box, Chip, Tooltip } from "@mui/material"; +import { + CloudDone as SavedIcon, + CloudSync as SavingIcon, + CloudOff as ErrorIcon, + PauseCircleOutline as PausedIcon, +} from "@mui/icons-material"; +import { SECTION_LABELS } from "./useBlogAdmin"; + +const formatRelative = (ts) => { + if (!ts) return ""; + const secs = Math.max(1, Math.round((Date.now() - ts) / 1000)); + if (secs < 60) return `${secs}s ago`; + const mins = Math.round(secs / 60); + if (mins < 60) return `${mins}m ago`; + return new Date(ts).toLocaleTimeString(); +}; + +const SaveIndicator = ({ saveState, dirtySections }) => { + const [, force] = useState(0); + useEffect(() => { + const t = setInterval(() => force((n) => n + 1), 15000); + return () => clearInterval(t); + }, []); + + if (dirtySections && dirtySections.size > 0) { + const labels = Array.from(dirtySections).map((s) => SECTION_LABELS[s] || s).join(", "); + return ( + + } + label={`Unsaved · ${labels}`} + color="warning" + size="small" + variant="outlined" + /> + + ); + } + + if (saveState.status === "saving") { + return } label="Saving…" size="small" color="info" variant="outlined" />; + } + if (saveState.status === "error") { + return ( + + } label="Save failed" size="small" color="error" /> + + ); + } + if (saveState.status === "saved" && saveState.lastSavedAt) { + return } label={`Saved · ${formatRelative(saveState.lastSavedAt)}`} size="small" color="success" variant="outlined" />; + } + return ; +}; + +export default SaveIndicator; diff --git a/src/components/admin/blog-edit/sections/ContentSection.js b/src/components/admin/blog-edit/sections/ContentSection.js new file mode 100644 index 00000000..7b532460 --- /dev/null +++ b/src/components/admin/blog-edit/sections/ContentSection.js @@ -0,0 +1,148 @@ +import React from "react"; +import dynamic from "next/dynamic"; +import { + Alert, + Box, + FormControlLabel, + Stack, + Switch, + TextField, + Typography, +} from "@mui/material"; +import SectionContainer from "../../hackathon-edit/SectionContainer"; +import ImageUpload from "../../ImageUpload"; +import * as ga from "../../../../lib/ga"; + +// MDEditor pulls in a chunky bundle; load on the client and avoid SSR. +const MDEditor = dynamic( + () => import("@uiw/react-md-editor").then((mod) => mod.default), + { ssr: false, loading: () => } +); + +const ContentSection = ({ admin, accessToken, orgId, onSnack }) => { + const { post, setField, markSectionDirty, dirtySections, commitSection, discardSection, saveState } = admin; + if (!post) return null; + + const dirty = dirtySections.has("content"); + const isMarkdown = post.content_format === "markdown"; + + const onFieldChange = (field) => (e) => { + setField(field, e.target.value); + markSectionDirty("content", true); + }; + + const onMarkdownChange = (value) => { + setField("content_markdown", value || ""); + markSectionDirty("content", true); + }; + + const onFormatToggle = (e) => { + setField("content_format", e.target.checked ? "markdown" : "html"); + markSectionDirty("content", true); + }; + + const onImageChange = (url) => { + setField("featured_image", url); + setField("image", url); + markSectionDirty("content", true); + }; + + const handleSave = async () => { + const res = await commitSection("content"); + if (res?.ok) { + onSnack?.("Content saved", "success"); + ga.trackStructuredEvent(ga.EventCategory.ADMIN, "admin_blog_edit_save", "content", null, { + post_id: post.id, + }); + } else if (res?.error) { + onSnack?.(`Save failed: ${res.error}`, "error"); + } + }; + + const handleDiscard = () => { + discardSection("content"); + onSnack?.("Reverted content changes", "info"); + }; + + return ( + + + + + + + + + Body + } + label={isMarkdown ? "Markdown" : "Plain text (legacy)"} + /> + + + {isMarkdown ? ( + + + + {(post.content_markdown || "").length.toLocaleString()} characters · GFM markdown supported + + + ) : ( + <> + + + {(post.description || "").length.toLocaleString()} characters + + + Tip: switch to Markdown for a better authoring experience — headers, lists, links, and images render nicely on the public page. + + + )} + + + {isMarkdown && (post.description || "").length > 0 && ( + + The legacy description field still has content. It will continue to be used as a short excerpt and in the SEO description fallback. Edit it by toggling back to Plain text. + + )} + + + ); +}; + +export default ContentSection; diff --git a/src/components/admin/blog-edit/sections/MetadataSection.js b/src/components/admin/blog-edit/sections/MetadataSection.js new file mode 100644 index 00000000..8b21734e --- /dev/null +++ b/src/components/admin/blog-edit/sections/MetadataSection.js @@ -0,0 +1,215 @@ +import React, { useState } from "react"; +import { + Alert, + Box, + Button, + Chip, + IconButton, + Stack, + TextField, + Typography, + ToggleButton, + ToggleButtonGroup, +} from "@mui/material"; +import { + Add as AddIcon, + Close as CloseIcon, + Launch as LaunchIcon, + Publish as PublishIcon, + Drafts as DraftsIcon, + Archive as ArchiveIcon, +} from "@mui/icons-material"; +import SectionContainer from "../../hackathon-edit/SectionContainer"; +import * as ga from "../../../../lib/ga"; + +const slugify = (raw) => + (raw || "") + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80); + +const MetadataSection = ({ admin, onSnack }) => { + const { post, setField, setStatus } = admin; + const [tagDraft, setTagDraft] = useState(""); + + if (!post) return null; + + const tags = Array.isArray(post.tags) ? post.tags : []; + + const onAuthorChange = (field) => (e) => { + setField("author", { ...(post.author || {}), [field]: e.target.value }); + }; + + const addTag = () => { + const trimmed = tagDraft.trim().replace(/^#/, ""); + if (!trimmed) return; + if (tags.includes(trimmed)) { + setTagDraft(""); + return; + } + setField("tags", [...tags, trimmed]); + setTagDraft(""); + }; + + const removeTag = (t) => setField("tags", tags.filter((x) => x !== t)); + + const handleStatusChange = async (_e, newStatus) => { + if (!newStatus || newStatus === post.status) return; + await setStatus(newStatus); + onSnack?.(`Status: ${newStatus}`, "success"); + if (newStatus === "published") { + ga.trackStructuredEvent(ga.EventCategory.ADMIN, "admin_blog_publish", post.id); + } else if (newStatus === "draft") { + ga.trackStructuredEvent(ga.EventCategory.ADMIN, "admin_blog_unpublish", post.id); + } else if (newStatus === "archived") { + ga.trackStructuredEvent(ga.EventCategory.ADMIN, "admin_blog_archive", post.id); + } + }; + + const fillSlugFromTitle = () => { + setField("slug", slugify(post.title)); + }; + + const gaUrl = `https://analytics.google.com/analytics/web/#/p${process.env.NEXT_PUBLIC_GA_PROPERTY_ID || ""}/reports/explorer?params=_u..nav%3Dmaui&collectionId=user&reportId=lifecycle-engagement-pages-and-screens&_r.explorerCard..fieldRefs=%5B%7B%22fieldType%22%3A%22dimension%22%2C%22fieldName%22%3A%22pagePath%22%7D%5D&_r.explorerCard..segment_keep=true&_r.explorerCard..filters=%5B%7B%22type%22%3A2%2C%22fieldName%22%3A%22pagePath%22%2C%22evaluationType%22%3A1%2C%22expressionList%22%3A%5B%22%2Fblog%2F${post.id}%22%5D%2C%22complement%22%3Afalse%7D%5D`; + + return ( + + + + Status + + + Draft + + + Published + + + Archived + + + + Draft and Archived posts are hidden from the public blog. Status change saves immediately. + + + + + Tags + + {tags.map((t) => ( + removeTag(t)} deleteIcon={} /> + ))} + {tags.length === 0 && ( + + No tags yet. Tags drive the related-posts widget and appear as chips on the public page. + + )} + + + setTagDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + addTag(); + } + }} + placeholder="Add a tag and press Enter" + fullWidth + /> + + + + + + + + Author + + + + + + Appears on the public page as "by Name" and in the article:author meta tag. + + + + + + Slug + + + setField("slug", e.target.value)} + fullWidth + placeholder={slugify(post.title) || "my-blog-post"} + helperText="Stored for future use. URLs currently still use the post ID." + /> + + + setField("published_at", e.target.value ? new Date(e.target.value).toISOString() : "")} + InputLabelProps={{ shrink: true }} + helperText="Shown on the public page and used in article:published_time. Leave blank to use the Slack timestamp." + /> + + } action={ + + }> + Per-post engagement metrics live in Google Analytics. Filter by pagePath = /blog/{post.id} to see views, scroll depth, share clicks, and external-link clicks for this post. + + + + ); +}; + +// Convert ISO string to the value expected by +function toDatetimeLocal(iso) { + try { + const d = new Date(iso); + if (isNaN(d.getTime())) return ""; + const pad = (n) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; + } catch { + return ""; + } +} + +export default MetadataSection; diff --git a/src/components/admin/blog-edit/sections/SeoSection.js b/src/components/admin/blog-edit/sections/SeoSection.js new file mode 100644 index 00000000..ec9349bb --- /dev/null +++ b/src/components/admin/blog-edit/sections/SeoSection.js @@ -0,0 +1,255 @@ +import React, { useMemo, useState } from "react"; +import { + Alert, + Box, + Chip, + IconButton, + Paper, + Stack, + TextField, + Typography, +} from "@mui/material"; +import { Add as AddIcon, Close as CloseIcon } from "@mui/icons-material"; +import SectionContainer from "../../hackathon-edit/SectionContainer"; +import * as ga from "../../../../lib/ga"; + +const CharCount = ({ value, max, idealMin = 0 }) => { + const len = (value || "").length; + let color = "text.secondary"; + if (len > max) color = "error.main"; + else if (len > max * 0.95) color = "warning.main"; + else if (len >= idealMin) color = "success.main"; + return ( + + {len} / {max} + + ); +}; + +const SerpPreview = ({ title, description, url }) => ( + + Google search result preview + + {url} + + {title || "(missing title)"} + + + {description || "(missing description)"} + + + +); + +const SocialPreview = ({ title, description, image, url }) => ( + + + Social share preview (Twitter / LinkedIn) + + + {image ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + No image + + )} + + {(() => { + try { return new URL(url).hostname; } catch { return "ohack.dev"; } + })()} + {title || "(missing title)"} + {description || ""} + + + +); + +const SeoSection = ({ admin, onSnack }) => { + const { post, setSeo, markSectionDirty, dirtySections, commitSection, discardSection, saveState } = admin; + const [keywordDraft, setKeywordDraft] = useState(""); + + if (!post) return null; + + const dirty = dirtySections.has("seo"); + const seo = post.seo || {}; + + const onSeoChange = (field) => (e) => { + setSeo(field, e.target.value); + markSectionDirty("seo", true); + }; + + const addKeyword = () => { + const trimmed = keywordDraft.trim(); + if (!trimmed) return; + const current = Array.isArray(seo.keywords) ? seo.keywords : []; + if (current.includes(trimmed)) { + setKeywordDraft(""); + return; + } + setSeo("keywords", [...current, trimmed]); + markSectionDirty("seo", true); + setKeywordDraft(""); + }; + + const removeKeyword = (kw) => { + const next = (seo.keywords || []).filter((k) => k !== kw); + setSeo("keywords", next); + markSectionDirty("seo", true); + }; + + // Effective values shown on the public page when fields are blank. + const effectiveTitle = seo.title || (post.title ? `OHack Blog: ${post.title}` : ""); + const effectiveDescription = seo.description || post.description || ""; + const effectiveImage = seo.og_image || post.featured_image || post.image || ""; + const effectiveUrl = seo.canonical || (post.id ? `https://ohack.dev/blog/${post.id}` : "https://ohack.dev/blog"); + + const handleSave = async () => { + const res = await commitSection("seo"); + if (res?.ok) { + onSnack?.("SEO saved", "success"); + ga.trackStructuredEvent(ga.EventCategory.ADMIN, "admin_blog_edit_save", "seo", null, { + post_id: post.id, + }); + } else if (res?.error) { + onSnack?.(`Save failed: ${res.error}`, "error"); + } + }; + + const handleDiscard = () => { + discardSection("seo"); + onSnack?.("Reverted SEO changes", "info"); + }; + + return ( + + + + + SEO title — shown in search results + + + + + + + + Meta description + + + + + + + Keywords + + {(seo.keywords || []).map((kw) => ( + removeKeyword(kw)} + deleteIcon={} + /> + ))} + {(seo.keywords || []).length === 0 && ( + + No custom keywords — tags and extracted hashtags will be used as a fallback. + + )} + + + setKeywordDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + addKeyword(); + } + }} + placeholder="Add a keyword and press Enter" + fullWidth + /> + + + + + + + + + + + {!post.title && ( + Add a post title in the Content section first — the SEO preview needs it. + )} + + + + + + + + ); +}; + +export default SeoSection; diff --git a/src/components/admin/blog-edit/sectionsManifest.js b/src/components/admin/blog-edit/sectionsManifest.js new file mode 100644 index 00000000..068efa1b --- /dev/null +++ b/src/components/admin/blog-edit/sectionsManifest.js @@ -0,0 +1,18 @@ +// Sidebar manifest for the per-post blog editor. +// Add a new section: append here, then implement `./sections/Section.js`. + +import { + Article as ContentIcon, + Search as SeoIcon, + Tag as MetadataIcon, +} from "@mui/icons-material"; + +export const SECTIONS = [ + { slug: "content", label: "Content", icon: ContentIcon, hint: "Title, body, featured image" }, + { slug: "seo", label: "SEO", icon: SeoIcon, hint: "Title tag, meta description, canonical, OG" }, + { slug: "metadata", label: "Metadata", icon: MetadataIcon, hint: "Author, tags, status, publish date" }, +]; + +export const DEFAULT_SECTION = "content"; + +export const isValidSection = (slug) => SECTIONS.some((s) => s.slug === slug); diff --git a/src/components/admin/blog-edit/useBlogAdmin.js b/src/components/admin/blog-edit/useBlogAdmin.js new file mode 100644 index 00000000..86a0c921 --- /dev/null +++ b/src/components/admin/blog-edit/useBlogAdmin.js @@ -0,0 +1,311 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +const SAVE_DEBOUNCE_MS = 1500; + +const cloneDeep = (v) => + typeof structuredClone === "function" ? structuredClone(v) : JSON.parse(JSON.stringify(v)); + +// Sections that require an explicit Save action (never autosave). +// Body content shouldn't silently save mid-keystroke; SEO changes should be deliberate. +export const EXPLICIT_SAVE_SECTIONS = new Set(["content", "seo"]); + +export const SECTION_LABELS = { + content: "Content", + seo: "SEO", + metadata: "Metadata", +}; + +const buildHeaders = (accessToken, orgId) => ({ + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + ...(orgId ? { "X-Org-Id": orgId } : {}), +}); + +// Top-level keys owned by each explicit-save section. Used to pause autosave +// for those keys while the section has unsaved changes. +function collectKeysForSections(sectionSet) { + const keys = new Set(); + sectionSet.forEach((s) => { + if (s === "content") { + keys.add("title"); + keys.add("description"); + keys.add("content_markdown"); + keys.add("content_format"); + keys.add("featured_image"); + keys.add("image"); + } else if (s === "seo") { + keys.add("seo"); + } + }); + return keys; +} + +const normalizePost = (raw) => ({ + ...raw, + title: raw.title || "", + description: raw.description || "", + content_markdown: raw.content_markdown || "", + content_format: raw.content_format || (raw.content_markdown ? "markdown" : "html"), + featured_image: raw.featured_image || raw.image || "", + image: raw.image || raw.featured_image || "", + tags: Array.isArray(raw.tags) ? raw.tags : [], + status: raw.status || "published", + slug: raw.slug || "", + published_at: raw.published_at || raw.slack_ts_human_readable || "", + author: raw.author || null, + seo: { + title: raw.seo?.title || "", + description: raw.seo?.description || "", + keywords: Array.isArray(raw.seo?.keywords) ? raw.seo.keywords : [], + canonical: raw.seo?.canonical || "", + og_image: raw.seo?.og_image || "", + }, + links: Array.isArray(raw.links) ? raw.links : [], +}); + +/** + * Per-post blog admin state machine. Mirrors useHackathonAdmin. + * + * draft — in-memory edit, what the form renders + * committed — last server-confirmed snapshot (autosave diffs against this) + * + * Hybrid save model: autosave runs for keys NOT owned by any dirty + * explicit-save section. Explicit sections call commitSection(name) to flush. + */ +export function useBlogAdmin({ postId, accessToken, orgId, isAdmin }) { + const [draft, setDraft] = useState(null); + const [committed, setCommitted] = useState(null); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [saveState, setSaveState] = useState({ status: "idle", lastSavedAt: null, error: null }); + const [dirtySections, setDirtySections] = useState(new Set()); + const saveTimerRef = useRef(null); + const inflightRef = useRef(null); + + const apiBase = process.env.NEXT_PUBLIC_API_SERVER_URL; + + const fetchPost = useCallback(async () => { + if (!isAdmin || !postId || !accessToken) return; + setLoading(true); + setLoadError(null); + try { + // Public single-news route works for admins reading a post by id. + const res = await fetch(`${apiBase}/api/messages/news/${postId}`, { + headers: buildHeaders(accessToken, orgId), + }); + if (!res.ok) throw new Error(`Failed to load post (${res.status})`); + const data = await res.json(); + const post = data?.text; + if (!post || !post.title) { + // Fall back to the admin list so drafts (filtered out of /news) are reachable. + const adminRes = await fetch(`${apiBase}/api/messages/admin/news?limit=2000`, { + headers: buildHeaders(accessToken, orgId), + }); + if (adminRes.ok) { + const adminData = await adminRes.json(); + const found = (adminData.text || []).find((p) => p.id === postId); + if (found) { + const normalized = normalizePost({ ...found, id: postId }); + setDraft(normalized); + setCommitted(cloneDeep(normalized)); + return; + } + } + setLoadError(`No blog post with id "${postId}"`); + setDraft(null); + setCommitted(null); + return; + } + const normalized = normalizePost({ ...post, id: postId }); + setDraft(normalized); + setCommitted(cloneDeep(normalized)); + } catch (err) { + console.error("Blog post load failed:", err); + setLoadError(err.message || "Failed to load"); + } finally { + setLoading(false); + } + }, [apiBase, postId, accessToken, orgId, isAdmin]); + + useEffect(() => { + fetchPost(); + }, [fetchPost]); + + const pushPatch = useCallback( + async (payload) => { + const myToken = Symbol("save"); + inflightRef.current = myToken; + const { id, ...patch } = payload; + const res = await fetch(`${apiBase}/api/messages/admin/news/${postId}`, { + method: "PATCH", + headers: buildHeaders(accessToken, orgId), + body: JSON.stringify(patch), + }); + if (inflightRef.current !== myToken) return null; + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(text || `Save failed (${res.status})`); + } + return payload; + }, + [apiBase, accessToken, orgId, postId] + ); + + // Autosave payload = committed + draft-for-non-dirty-explicit-section-keys + const buildAutosavePayload = useCallback(() => { + if (!draft || !committed) return null; + const dirtyKeys = collectKeysForSections(dirtySections); + const merged = { ...committed }; + Object.keys(draft).forEach((k) => { + if (!dirtyKeys.has(k)) merged[k] = draft[k]; + }); + return merged; + }, [draft, committed, dirtySections]); + + useEffect(() => { + if (!draft || !committed) return; + if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + const payload = buildAutosavePayload(); + if (!payload) return; + if (JSON.stringify(payload) === JSON.stringify(committed)) return; + + saveTimerRef.current = setTimeout(async () => { + setSaveState({ status: "saving", lastSavedAt: null, error: null }); + try { + const saved = await pushPatch(payload); + if (saved) { + setCommitted(cloneDeep(saved)); + setSaveState({ status: "saved", lastSavedAt: Date.now(), error: null }); + } + } catch (err) { + console.error("Autosave failed:", err); + setSaveState({ status: "error", lastSavedAt: null, error: err.message }); + } + }, SAVE_DEBOUNCE_MS); + return () => clearTimeout(saveTimerRef.current); + }, [draft, committed, buildAutosavePayload, pushPatch]); + + const setField = useCallback((field, value) => { + setDraft((prev) => (prev ? { ...prev, [field]: value } : prev)); + }, []); + + const setSeo = useCallback((field, value) => { + setDraft((prev) => + prev ? { ...prev, seo: { ...(prev.seo || {}), [field]: value } } : prev + ); + }, []); + + const markSectionDirty = useCallback((section, dirty) => { + setDirtySections((prev) => { + const next = new Set(prev); + if (dirty) next.add(section); + else next.delete(section); + return next; + }); + }, []); + + const commitSection = useCallback( + async (section) => { + if (!draft) return { ok: false, error: "No draft" }; + setSaveState({ status: "saving", lastSavedAt: null, error: null }); + try { + const saved = await pushPatch(draft); + if (saved) { + setCommitted(cloneDeep(saved)); + setSaveState({ status: "saved", lastSavedAt: Date.now(), error: null }); + markSectionDirty(section, false); + return { ok: true }; + } + return { ok: false, error: "Concurrent save" }; + } catch (err) { + setSaveState({ status: "error", lastSavedAt: null, error: err.message }); + return { ok: false, error: err.message }; + } + }, + [draft, pushPatch, markSectionDirty] + ); + + const discardSection = useCallback( + (section) => { + if (!committed) return; + const keys = collectKeysForSections(new Set([section])); + setDraft((prev) => { + if (!prev) return prev; + const next = { ...prev }; + keys.forEach((k) => { + next[k] = cloneDeep(committed[k]); + }); + return next; + }); + markSectionDirty(section, false); + }, + [committed, markSectionDirty] + ); + + useEffect(() => { + const handler = (e) => { + if (dirtySections.size === 0) return; + e.preventDefault(); + e.returnValue = ""; + }; + window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); + }, [dirtySections]); + + // Convenience: publish / unpublish flips status and immediately saves (autosave path). + const setStatus = useCallback( + async (newStatus) => { + setDraft((prev) => (prev ? { ...prev, status: newStatus } : prev)); + // Force-flush via direct PATCH so the user sees the chip change immediately, + // bypassing the debounce. Don't await dirty-section conflicts — status is in + // MetadataSection which is autosaved. + try { + await fetch(`${apiBase}/api/messages/admin/news/${postId}`, { + method: "PATCH", + headers: buildHeaders(accessToken, orgId), + body: JSON.stringify({ status: newStatus }), + }); + setCommitted((prev) => (prev ? { ...prev, status: newStatus } : prev)); + setSaveState({ status: "saved", lastSavedAt: Date.now(), error: null }); + } catch (err) { + setSaveState({ status: "error", lastSavedAt: null, error: err.message }); + } + }, + [apiBase, accessToken, orgId, postId] + ); + + const value = useMemo( + () => ({ + post: draft, + committed, + loading, + loadError, + saveState, + dirtySections, + setField, + setSeo, + setStatus, + markSectionDirty, + commitSection, + discardSection, + refetch: fetchPost, + }), + [ + draft, + committed, + loading, + loadError, + saveState, + dirtySections, + setField, + setSeo, + setStatus, + markSectionDirty, + commitSection, + discardSection, + fetchPost, + ] + ); + + return value; +} diff --git a/src/components/admin/checkin/CheckInWorkbench.js b/src/components/admin/checkin/CheckInWorkbench.js new file mode 100644 index 00000000..68376792 --- /dev/null +++ b/src/components/admin/checkin/CheckInWorkbench.js @@ -0,0 +1,783 @@ +import React, { useState, useEffect, useCallback } from "react"; +import { useAuthInfo, withRequiredAuthInfo } from "@propelauth/react"; +import { + Typography, + Box, + Button, + Paper, + Alert, + CircularProgress, + Card, + CardContent, + Grid, + Chip, + Divider, + useTheme, + useMediaQuery, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + FormControl, + InputLabel, + Select, + MenuItem, +} from "@mui/material"; +import QrScanner from 'react-qr-scanner'; +import AdminPage from "../../../components/admin/AdminPage"; +import { useRouter } from "next/router"; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import ErrorIcon from '@mui/icons-material/Error'; +import QrCodeScannerIcon from '@mui/icons-material/QrCodeScanner'; +import PersonIcon from '@mui/icons-material/Person'; +import useHackathonEvents from "../../../hooks/use-hackathon-events"; + +// When `embedded` is true, this component is rendered inside the per-event +// hackathon admin page. The host page already owns the AdminPage wrapper and +// the event selection (via URL), so we skip both chrome layers and use +// `externalEventId` as the pinned event id. +const CheckInWorkbench = ({ userClass, embedded = false, externalEventId, onSnack }) => { + const { accessToken, user } = useAuthInfo(); + const router = useRouter(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("sm")); + + // Authentication + const org = userClass.getOrgByName("Opportunity Hack Org"); + const orgId = org.orgId; + const isAdmin = org.hasPermission("volunteer.admin"); + + // Hackathon events + const { hackathons = [] } = useHackathonEvents(false) || {}; + + // State + const [selectedEventId, setSelectedEventId] = useState(""); + const [checkInCounts, setCheckInCounts] = useState({ + mentor: 0, + judge: 0, + volunteer: 0, + hacker: 0, + sponsor: 0 + }); + const [scanning, setScanning] = useState(false); + const [lastScanned, setLastScanned] = useState(null); + const [scanResult, setScanResult] = useState(null); + const [checkInHistory, setCheckInHistory] = useState([]); + const [allCheckedInPeople, setAllCheckedInPeople] = useState([]); + const [loading, setLoading] = useState(false); + const [snackbar, setSnackbar] = useState({ + open: false, + message: "", + severity: "success", + }); + const [manualEntryOpen, setManualEntryOpen] = useState(false); + const [manualEventId, setManualEventId] = useState(""); + const [manualVolunteerId, setManualVolunteerId] = useState(""); + const [manualVolunteerType, setManualVolunteerType] = useState(""); + + // Scanner settings + const [facingMode, setFacingMode] = useState('environment'); // 'user' for front camera, 'environment' for back camera + + // Fetch check-in counts for all volunteer types + const fetchCheckInCounts = useCallback(async (eventId) => { + if (!eventId) return; + + const volunteerTypes = ['mentor', 'judge', 'volunteer', 'hacker', 'sponsor']; + const counts = {}; + + try { + const promises = volunteerTypes.map(async (type) => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/messages/hackathon/${eventId}/${type}/checkins`, + { + headers: { + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + "X-Org-Id": orgId, + }, + } + ); + + if (response.ok) { + const data = await response.json(); + console.log(`Fetched ${type} check-ins:`, data); + // Count volunteers that are checked in + const checkedInCount = Array.isArray(data.data) + ? data.data.filter(volunteer => volunteer.checkedIn === true).length + : 0; + + console.log(`Counted ${checkedInCount} checked-in ${type}s`); + return { type, count: checkedInCount }; + } else { + return { type, count: 0 }; + } + }); + + const results = await Promise.all(promises); + results.forEach(({ type, count }) => { + counts[type] = count; + }); + + setCheckInCounts(counts); + } catch (error) { + console.error('Error fetching check-in counts:', error); + // Reset counts on error + setCheckInCounts({ + mentor: 0, + judge: 0, + volunteer: 0, + hacker: 0, + sponsor: 0 + }); + } + }, [accessToken, orgId]); + + // Fetch all checked-in people with details + const fetchAllCheckedInPeople = useCallback(async (eventId) => { + if (!eventId) return; + + const volunteerTypes = ['mentor', 'judge', 'volunteer', 'hacker', 'sponsor']; + const allPeople = []; + + try { + const promises = volunteerTypes.map(async (type) => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/messages/hackathon/${eventId}/${type}/checkins`, + { + headers: { + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + "X-Org-Id": orgId, + }, + } + ); + + if (response.ok) { + const data = await response.json(); + // Get only checked-in volunteers with their full details + const checkedInPeople = Array.isArray(data.data) + ? data.data.filter(volunteer => volunteer.checkedIn === true).map(volunteer => ({ + ...volunteer, + volunteerType: type, + eventId: eventId + })) + : []; + + return checkedInPeople; + } else { + return []; + } + }); + + const results = await Promise.all(promises); + const flattenedPeople = results.flat(); + + // Sort by check-in time (most recent first) + const sortedPeople = flattenedPeople.sort((a, b) => { + const timeA = new Date(a.checkedInAt || 0); + const timeB = new Date(b.checkedInAt || 0); + return timeB - timeA; + }); + + setAllCheckedInPeople(sortedPeople); + } catch (error) { + console.error('Error fetching checked-in people:', error); + setAllCheckedInPeople([]); + } + }, [accessToken, orgId]); + + // Handle hackathon event selection + const handleEventChange = (eventId) => { + setSelectedEventId(eventId); + setManualEventId(eventId); // Also set it for manual entry + + // Update URL to reflect the selected event without page reload + if (eventId) { + const currentPath = router.asPath.split('?')[0]; + router.replace(`${currentPath}?event_id=${eventId}`, undefined, { shallow: true }); + fetchCheckInCounts(eventId); + fetchAllCheckedInPeople(eventId); + } else { + // Remove event_id parameter from URL + router.replace(router.pathname, undefined, { shallow: true }); + setCheckInCounts({ + mentor: 0, + judge: 0, + volunteer: 0, + hacker: 0, + sponsor: 0 + }); + setAllCheckedInPeople([]); + } + }; + + // Handle URL parameters and initial event selection + useEffect(() => { + if (embedded) { + if (externalEventId && externalEventId !== selectedEventId) { + setSelectedEventId(externalEventId); + setManualEventId(externalEventId); + } + return; + } + const { event_id } = router.query; + if (event_id && hackathons.length > 0) { + // Check if the event_id from URL parameter exists in our hackathons list + const eventExists = hackathons.some(hackathon => hackathon.event_id === event_id); + if (eventExists && event_id !== selectedEventId) { + setSelectedEventId(event_id); + setManualEventId(event_id); + } + } + }, [embedded, externalEventId, router.query, hackathons, selectedEventId]); + + // Auto-refresh check-in counts every 7 seconds + useEffect(() => { + if (!selectedEventId) return; + + fetchCheckInCounts(selectedEventId); + fetchAllCheckedInPeople(selectedEventId); + + const interval = setInterval(() => { + fetchCheckInCounts(selectedEventId); + fetchAllCheckedInPeople(selectedEventId); + }, 7000); + + return () => clearInterval(interval); + }, [selectedEventId, fetchCheckInCounts, fetchAllCheckedInPeople]); + + // Define processCheckIn before handleScan + const processCheckIn = useCallback(async (eventId, volunteerId, volunteerType) => { + setLoading(true); + try { + // First, fetch the volunteer data to verify it exists + const fetchUrl = `${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/messages/admin/hackathon/${eventId}/${volunteerType}`; + const response = await fetch(fetchUrl, { + headers: { + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + "X-Org-Id": orgId, + }, + }); + + if (!response.ok) { + throw new Error('Failed to fetch volunteer data'); + } + + const data = await response.json(); + const volunteers = data.data || []; + + // Find the specific volunteer by ID + const volunteer = volunteers.find(v => v.id === volunteerId); + if (!volunteer) { + throw new Error(`${volunteerType} with ID ${volunteerId} not found for event ${eventId}`); + } + + // Update volunteer with check-in timestamp and admin info + const updatedVolunteer = { + ...volunteer, + checkedInAt: new Date().toISOString(), + checkedIn: true, + checkedInBy: { + userId: user?.userId, + email: user?.email, + firstName: user?.firstName, + lastName: user?.lastName + } + }; + + // Send update to API + const updateUrl = `${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/messages/hackathon/${eventId}/${volunteerType}`; + const updateResponse = await fetch(updateUrl, { + method: "PATCH", + headers: { + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + "X-Org-Id": orgId, + }, + body: JSON.stringify(updatedVolunteer), + }); + + if (!updateResponse.ok) { + throw new Error('Failed to update volunteer check-in status'); + } + + // Add to check-in history + const checkInRecord = { + id: Date.now(), + eventId, + volunteerId, + volunteerType, + volunteerName: volunteer.name || 'Unknown', + timestamp: new Date().toISOString(), + success: true + }; + + setCheckInHistory(prev => [checkInRecord, ...prev.slice(0, 9)]); // Keep last 10 records + + setSnackbar({ + open: true, + message: `Successfully checked in ${volunteer.name || 'volunteer'}`, + severity: "success", + }); + + // Refresh check-in counts and people list after successful check-in + if (selectedEventId) { + fetchCheckInCounts(selectedEventId); + fetchAllCheckedInPeople(selectedEventId); + } + + } catch (error) { + console.error('Check-in error:', error); + + // Add failed attempt to history + const failedRecord = { + id: Date.now(), + eventId, + volunteerId, + volunteerType, + timestamp: new Date().toISOString(), + success: false, + error: error.message + }; + + setCheckInHistory(prev => [failedRecord, ...prev.slice(0, 9)]); + + setSnackbar({ + open: true, + message: `Check-in failed: ${error.message}`, + severity: "error", + }); + } finally { + setLoading(false); + } + }, [accessToken, orgId, selectedEventId, fetchCheckInCounts, fetchAllCheckedInPeople, user?.userId, user?.email, user?.firstName, user?.lastName]); + + const handleScan = useCallback(async (data) => { + if (data && data.text && data.text !== lastScanned) { + setLastScanned(data.text); + setScanning(false); + + try { + // Parse QR code data + // Expected format: eventId|volunteerId|volunteerType + const parts = data.text.split('|'); + if (parts.length !== 3) { + throw new Error('Invalid QR code format. Expected: eventId|volunteerId|volunteerType'); + } + + const [eventId, volunteerId, volunteerType] = parts; + + // Validate the data + if (!eventId || !volunteerId || !volunteerType) { + throw new Error('Missing required information in QR code'); + } + + const validTypes = ['mentor', 'judge', 'volunteer', 'hacker', 'sponsor']; + if (!validTypes.includes(volunteerType)) { + throw new Error(`Invalid volunteer type: ${volunteerType}`); + } + + setScanResult({ + eventId, + volunteerId, + volunteerType, + timestamp: new Date().toISOString(), + success: true + }); + + // Process check-in + await processCheckIn(eventId, volunteerId, volunteerType); + + } catch (error) { + console.error('QR code processing error:', error); + setScanResult({ + error: error.message, + timestamp: new Date().toISOString(), + success: false + }); + setSnackbar({ + open: true, + message: `Check-in failed: ${error.message}`, + severity: "error", + }); + } + } + }, [lastScanned, processCheckIn]); + + const handleError = useCallback((err) => { + console.error('QR Scanner error:', err); + setSnackbar({ + open: true, + message: "Camera error. Please check camera permissions and try again.", + severity: "error", + }); + }, []); + + const startScanning = () => { + setScanning(true); + setScanResult(null); + setLastScanned(null); + }; + + const stopScanning = () => { + setScanning(false); + }; + + const switchCamera = () => { + setFacingMode(facingMode === 'environment' ? 'user' : 'environment'); + }; + + const handleManualCheckIn = async () => { + if (!manualEventId || !manualVolunteerId || !manualVolunteerType) { + setSnackbar({ + open: true, + message: "Please fill in all fields", + severity: "error", + }); + return; + } + + await processCheckIn(manualEventId, manualVolunteerId, manualVolunteerType); + setManualEntryOpen(false); + setManualEventId(""); + setManualVolunteerId(""); + setManualVolunteerType(""); + }; + + const handleViewVolunteer = (eventId, volunteerId, volunteerType) => { + // Navigate to volunteer admin with this specific volunteer + const url = `/admin/volunteer?event_id=${eventId}&volunteer_id=${volunteerId}&volunteer_type=${volunteerType}`; + router.push(url); + }; + + if (!isAdmin) { + if (embedded) return null; + return ( + + You do not have permission to view this page. + + ); + } + + const Outer = embedded ? React.Fragment : AdminPage; + const outerProps = embedded + ? {} + : { + title: "QR Code Check-In System", + isAdmin, + snackbar, + onSnackbarClose: () => setSnackbar({ ...snackbar, open: false }), + }; + + return ( + + {/* Event Selection and Check-in Counts */} + + + {!embedded && ( + + + + Hackathon Event + + + + {router.query.event_id && ( + + 📎 Event pre-selected from URL parameter + + )} + + )} + + {selectedEventId && ( + + + Real-time Check-in Counts + + + {[ + { key: 'mentor', label: 'Mentors', color: 'primary' }, + { key: 'judge', label: 'Judges', color: 'secondary' }, + { key: 'volunteer', label: 'Volunteers', color: 'success' }, + { key: 'hacker', label: 'Hackers', color: 'info' }, + { key: 'sponsor', label: 'Sponsors', color: 'warning' } + ].map(({ key, label, color }) => ( + + + + + {checkInCounts[key]} + + + {label} + + + + + ))} + + + Updates every 7 seconds + + + )} + + + + + + {/* Scanner Section */} + + + + + QR Code Scanner + + + {!scanning ? ( + + + Scan volunteer QR codes to check them in for hackathon events + + + + + ) : ( + + + + {loading && ( + + + + )} + + + + + + + + )} + + {/* Last Scan Result */} + {scanResult && ( + : } + > + {scanResult.success ? ( + + + Successfully scanned: + + + Event: {scanResult.eventId}
+ Volunteer Type: {scanResult.volunteerType}
+ ID: {scanResult.volunteerId} +
+
+ ) : ( + + Scan Error: {scanResult.error} + + )} +
+ )} +
+
+ + {/* All Checked-In People */} + + + + + All Checked-In People + {allCheckedInPeople.length > 0 && ( + + )} + + + {allCheckedInPeople.length === 0 ? ( + + No one has checked in yet + + ) : ( + + {allCheckedInPeople.map((person) => { + const isCheckedInByCurrentUser = person.checkedInBy?.userId === user?.userId; + return ( + handleViewVolunteer(person.eventId, person.id, person.volunteerType)} + > + + + } + label={isCheckedInByCurrentUser ? "You checked in" : "Checked in"} + color={isCheckedInByCurrentUser ? "primary" : "success"} + sx={{ mr: 1 }} + /> + + {person.checkedInAt ? new Date(person.checkedInAt).toLocaleString() : 'Unknown time'} + + + + + {person.name || 'Unknown'} + + + {person.volunteerType} • Click to view + + + {person.checkedInBy && !isCheckedInByCurrentUser && ( + + Checked in by: {person.checkedInBy.firstName} {person.checkedInBy.lastName} + + )} + + {person.checkedInBy && isCheckedInByCurrentUser && ( + + ✓ You checked this person in + + )} + + + ); + })} + + )} + + +
+ + {/* Manual Entry Dialog */} + setManualEntryOpen(false)} maxWidth="sm" fullWidth> + Manual Check-In Entry + + + setManualEventId(e.target.value)} + placeholder="e.g., 2025_fall" + /> + setManualVolunteerId(e.target.value)} + placeholder="e.g., 12345" + /> + setManualVolunteerType(e.target.value)} + placeholder="mentor, judge, volunteer, hacker, or sponsor" + /> + + + + + + + +
+ ); +}; + +const AdminCheckInPage = withRequiredAuthInfo(CheckInWorkbench); + +export default AdminCheckInPage; +export { CheckInWorkbench }; \ No newline at end of file diff --git a/src/components/admin/feedback/CrossEventSurveys.js b/src/components/admin/feedback/CrossEventSurveys.js new file mode 100644 index 00000000..f7d134d7 --- /dev/null +++ b/src/components/admin/feedback/CrossEventSurveys.js @@ -0,0 +1,366 @@ +import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"; +import { + Box, + Grid, + Card, + CardContent, + Typography, + CircularProgress, + Chip, + Stack, + Alert, + Rating, + Select, + MenuItem, + FormControl, + InputLabel, + Checkbox, + ListItemText, + OutlinedInput, + Table, + TableHead, + TableRow, + TableCell, + TableBody, +} from "@mui/material"; +import { + TrendingUp as UpIcon, + TrendingDown as DownIcon, +} from "@mui/icons-material"; +import { + ComposedChart, + LineChart, + Bar, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip as RTooltip, + Legend, + ResponsiveContainer, + ReferenceLine, + Cell, +} from "recharts"; +import { + parseTs, + elapsedHourSeries, + eventDurationHours, + SERIES_COLORS, +} from "./surveyAnalytics"; + +const API = process.env.NEXT_PUBLIC_API_SERVER_URL; +const LOW_N = 5; // events with fewer responses are flagged as small-sample + +const KpiCard = ({ label, children }) => ( + + + {label} + {children} + + +); + +const shortTitle = (t, n = 20) => (t && t.length > n ? `${t.slice(0, n - 1)}…` : t || ""); + +const CrossEventSurveys = ({ accessToken, orgId, onError }) => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [selectedIds, setSelectedIds] = useState([]); + const [overlaySeries, setOverlaySeries] = useState({}); // event_id -> [{hour, avgRating}] + const pending = useRef(new Set()); + + const headers = useMemo( + () => ({ authorization: `Bearer ${accessToken}`, "content-type": "application/json", "X-Org-Id": orgId }), + [accessToken, orgId] + ); + + // Cross-event aggregate (one server-side scan). + useEffect(() => { + let active = true; + (async () => { + setLoading(true); + try { + const res = await fetch(`${API}/api/surveys/overview`, { headers }); + if (!res.ok) throw new Error("overview"); + const json = await res.json(); + if (!active) return; + setData(json); + } catch (e) { + if (active) onError?.("Failed to load the cross-event survey overview."); + } finally { + if (active) setLoading(false); + } + })(); + return () => { active = false; }; + }, [headers, onError]); + + const events = data?.events || []; + const totals = data?.totals || {}; + + const eventsById = useMemo( + () => Object.fromEntries(events.map((e) => [e.event_id, e])), + [events] + ); + const titleFor = useCallback((id) => eventsById[id]?.title || id, [eventsById]); + + // Default the overlay to the most-recent events that have live responses. + useEffect(() => { + if (!data) return; + const liveEvents = events.filter((e) => (e.by_mode?.live || 0) > 0); + setSelectedIds(liveEvents.slice(-3).map((e) => e.event_id)); + }, [data]); // eslint-disable-line react-hooks/exhaustive-deps + + // Fetch live responses for any newly-selected event (only the handful chosen). + const fetchEventSeries = useCallback(async (ev) => { + const res = await fetch(`${API}/api/surveys/${ev.event_id}/responses?mode=live`, { headers }); + if (!res.ok) return []; + const json = await res.json(); + return elapsedHourSeries(json.responses || [], ev.start_date, { + liveOnly: true, + maxHours: eventDurationHours(ev.start_date, ev.end_date), + }); + }, [headers]); + + useEffect(() => { + let active = true; + const missing = selectedIds.filter((id) => !overlaySeries[id] && !pending.current.has(id) && eventsById[id]); + if (!missing.length) return; + missing.forEach((id) => pending.current.add(id)); + (async () => { + const results = await Promise.all( + missing.map((id) => fetchEventSeries(eventsById[id]).then((s) => [id, s]).catch(() => [id, []])) + ); + missing.forEach((id) => pending.current.delete(id)); + if (!active) return; + setOverlaySeries((prev) => { + const next = { ...prev }; + results.forEach(([id, s]) => { next[id] = s; }); + return next; + }); + })(); + return () => { active = false; }; + }, [selectedIds, eventsById, overlaySeries, fetchEventSeries]); + + // ----------------------------------------------------------------- derived + const macroData = useMemo( + () => events.map((e) => ({ + label: shortTitle(e.title || e.event_id), + date: e.start_date || "", + count: e.count, + rating: e.overall_rating?.average ?? null, + wouldReturn: e.would_return?.average ?? null, + lowN: e.count < LOW_N, + })), + [events] + ); + + const ratedEvents = useMemo( + () => events.filter((e) => e.overall_rating?.average != null), + [events] + ); + const latest = ratedEvents[ratedEvents.length - 1]; + const prior = ratedEvents[ratedEvents.length - 2]; + const latestDelta = latest && prior + ? +(latest.overall_rating.average - prior.overall_rating.average).toFixed(2) + : null; + const best = useMemo( + () => ratedEvents.reduce((b, e) => (!b || e.overall_rating.average > b.overall_rating.average ? e : b), null), + [ratedEvents] + ); + const worst = useMemo( + () => ratedEvents.reduce((w, e) => (!w || e.overall_rating.average < w.overall_rating.average ? e : w), null), + [ratedEvents] + ); + + // Merge selected events' elapsed series into one chart dataset keyed by hour. + const overlayData = useMemo(() => { + const ids = selectedIds.filter((id) => overlaySeries[id]?.length); + let maxH = 0; + ids.forEach((id) => overlaySeries[id].forEach((b) => { if (b.hour > maxH) maxH = b.hour; })); + const byHour = ids.map((id) => Object.fromEntries(overlaySeries[id].map((b) => [b.hour, b.avgRating]))); + const rows = []; + for (let h = 0; h <= maxH; h++) { + const row = { hour: h }; + ids.forEach((id, i) => { row[id] = byHour[i][h] ?? null; }); + rows.push(row); + } + return rows; + }, [selectedIds, overlaySeries]); + + if (loading) { + return ; + } + if (!events.length) { + return No survey responses recorded for any event yet.; + } + + const liveOptions = events.filter((e) => (e.by_mode?.live || 0) > 0); + + return ( + + {/* ------------------------------------------------------------ KPI band */} + + + + {totals.responses || 0} + across {totals.events || 0} events + + + + + + {latest ? latest.overall_rating.average.toFixed(2) : "—"} + {latestDelta != null && latestDelta !== 0 && ( + 0 ? "success.main" : "error.main" }}> + {latestDelta > 0 ? : } + {Math.abs(latestDelta).toFixed(2)} + + )} + + {latest ? shortTitle(latest.title, 24) : "—"} + + + + + {best ? best.overall_rating.average.toFixed(2) : "—"} + {best ? shortTitle(best.title, 24) : "—"} + + + + + {worst ? worst.overall_rating.average.toFixed(2) : "—"} + {worst ? shortTitle(worst.title, 24) : "—"} + + + + + {/* ------------------------------------------------ calendar macro-trend */} + + + Feedback across events (over time) + + + + + + + + + + + {macroData.map((d, i) => ( + + ))} + + + + + + + + Each point is one event, ordered by date. Lines = avg overall rating (solid) & would-return (dashed); bars = response volume (grey = small sample, n<{LOW_N}). + + + + + {/* --------------------------------------------------- elapsed overlay */} + + + + During-event feedback shape (overlay) + + Events to compare + + + + + {liveOptions.length === 0 ? ( + No events have live (during-event) responses to overlay yet. + ) : overlayData.length === 0 ? ( + + ) : ( + <> + + + + + + + `Hour ${h} since kickoff`} /> + + + + {selectedIds.filter((id) => overlaySeries[id]?.length).map((id, i) => ( + + ))} + + + + + Avg overall rating by hours since each event's kickoff (live responses only) — compare the shape of feedback across events. + + + )} + + + + {/* ---------------------------------------------------- per-event table */} + + + Per-event summary + + + + + Event + Date + Responses + Avg rating + Would return + Live / Post + + + + {[...events].reverse().map((e) => ( + + {e.title || e.event_id} + {e.start_date || "—"} + + {e.count}{e.count < LOW_N && } + + + {e.overall_rating?.average != null ? ( + + {e.overall_rating.average.toFixed(2)} + + + ) : "—"} + + {e.would_return?.average != null ? e.would_return.average.toFixed(2) : "—"} + {e.by_mode?.live || 0} / {e.by_mode?.post || 0} + + ))} + +
+
+
+
+
+ ); +}; + +export default CrossEventSurveys; diff --git a/src/components/admin/feedback/EventSurveysPanel.js b/src/components/admin/feedback/EventSurveysPanel.js new file mode 100644 index 00000000..c6b94247 --- /dev/null +++ b/src/components/admin/feedback/EventSurveysPanel.js @@ -0,0 +1,552 @@ +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { + Box, + Grid, + Card, + CardContent, + Typography, + Select, + MenuItem, + FormControl, + InputLabel, + CircularProgress, + Chip, + Stack, + Rating, + Divider, + Accordion, + AccordionSummary, + AccordionDetails, + Alert, + ToggleButton, + ToggleButtonGroup, + LinearProgress, + Tooltip as MuiTooltip, +} from "@mui/material"; +import { + ExpandMore as ExpandMoreIcon, + FormatQuote as QuoteIcon, + WarningAmber as WarningIcon, +} from "@mui/icons-material"; +import { + ComposedChart, + Bar, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip as RTooltip, + Legend, + ResponsiveContainer, + ReferenceLine, + Cell, +} from "recharts"; +import { + aggregate, + formatAnswer, + elapsedHourSeries, + eventDurationHours, + collectFreeText, + extractThemes, + redFlags, + gist, + QUESTIONS_BY_ID, + QUESTION_ORDER, + ROLE_LABEL, + qLabel, +} from "./surveyAnalytics"; +import CrossEventSurveys from "./CrossEventSurveys"; + +const API = process.env.NEXT_PUBLIC_API_SERVER_URL; + +const fmtDate = (iso) => { + if (!iso) return ""; + const d = new Date(iso); + return isNaN(d) ? "" : d.toLocaleString(); +}; + +// ------------------------------------------------------------------- pieces +const ScaleBars = ({ dist, count }) => ( + + {[5, 4, 3, 2, 1].map((n) => { + const c = dist[n] || 0; + const pct = count ? Math.round((c / count) * 100) : 0; + return ( + + {n} + + + + + {c} ({pct}%) + + + ); + })} + +); + +const ChoiceBars = ({ counts, total }) => { + const rows = Object.entries(counts).sort((a, b) => b[1] - a[1]); + return ( + + {rows.map(([opt, c]) => { + const pct = total ? Math.round((c / total) * 100) : 0; + return ( + + + {opt} + {c} ({pct}%) + + + + ); + })} + + ); +}; + +const KpiCard = ({ label, children }) => ( + + + {label} + {children} + + +); + +const ThemeChips = ({ themes, color }) => ( + + {themes.map((t) => ( + + + + ))} + +); + +// -------------------------------------------------------------------- panel +const EventSurveysPanel = ({ accessToken, orgId, onError }) => { + const [view, setView] = useState("single"); // "single" | "compare" + const [hackathons, setHackathons] = useState([]); + const [eventId, setEventId] = useState(""); + const [summary, setSummary] = useState(null); + const [responses, setResponses] = useState([]); + const [loadingEvents, setLoadingEvents] = useState(true); + const [loadingData, setLoadingData] = useState(false); + const [modeFilter, setModeFilter] = useState("all"); + + const headers = useMemo( + () => ({ authorization: `Bearer ${accessToken}`, "content-type": "application/json", "X-Org-Id": orgId }), + [accessToken, orgId] + ); + + // Hackathon list for the selector (default to the most recent). + useEffect(() => { + let active = true; + (async () => { + setLoadingEvents(true); + try { + const res = await fetch(`${API}/api/messages/hackathons`, { headers }); + if (!res.ok) throw new Error("hackathons"); + const data = await res.json(); + const sorted = [...(data.hackathons || [])].sort( + (a, b) => new Date(b.start_date) - new Date(a.start_date) + ); + if (!active) return; + setHackathons(sorted); + if (sorted.length) setEventId(sorted[0].event_id || sorted[0].id); + } catch (e) { + if (active) onError?.("Failed to load hackathons."); + } finally { + if (active) setLoadingEvents(false); + } + })(); + return () => { active = false; }; + }, [headers, onError]); + + const loadEvent = useCallback(async (id) => { + if (!id) return; + setLoadingData(true); + setSummary(null); + setResponses([]); + try { + const [sumRes, respRes] = await Promise.all([ + fetch(`${API}/api/surveys/${id}/summary`, { headers }), + fetch(`${API}/api/surveys/${id}/responses`, { headers }), + ]); + const sumJson = sumRes.ok ? await sumRes.json() : null; + const respJson = respRes.ok ? await respRes.json() : null; + setSummary(sumJson?.summary || null); + setResponses(respJson?.responses || []); + } catch (e) { + onError?.("Failed to load survey responses."); + } finally { + setLoadingData(false); + } + }, [headers, onError]); + + useEffect(() => { + if (view === "single" && eventId) loadEvent(eventId); + }, [view, eventId, loadEvent]); + + const filtered = useMemo( + () => (modeFilter === "all" ? responses : responses.filter((r) => r.mode === modeFilter)), + [responses, modeFilter] + ); + const agg = useMemo(() => aggregate(filtered), [filtered]); + + // Selected event metadata for the during-event pulse. + const selectedEvent = useMemo( + () => hackathons.find((h) => (h.event_id || h.id) === eventId), + [hackathons, eventId] + ); + const liveCount = useMemo(() => responses.filter((r) => r.mode === "live").length, [responses]); + const pulseSeries = useMemo( + () => elapsedHourSeries(responses, selectedEvent?.start_date, { + liveOnly: true, + maxHours: eventDurationHours(selectedEvent?.start_date, selectedEvent?.end_date), + }), + [responses, selectedEvent] + ); + + // Free-text themes (split positive vs improvement) + needs-attention. + const freeText = useMemo(() => collectFreeText(filtered), [filtered]); + const goingWellThemes = useMemo(() => extractThemes(freeText.positive), [freeText]); + const toImproveThemes = useMemo(() => extractThemes(freeText.negative), [freeText]); + const flags = useMemo(() => redFlags(filtered), [filtered]); + + // Questions that actually have data, in catalog order. + const textBlocks = QUESTION_ORDER.filter((id) => agg.texts[id]?.length); + const quantBlocks = QUESTION_ORDER.filter((id) => agg.scale[id] || agg.choice[id]); + + if (loadingEvents) { + return ; + } + + return ( + + v && setView(v)} + sx={{ mb: 3 }} + > + Single event + Compare events + + + {view === "compare" ? ( + + ) : ( + <> + + + Event + + + + v && setModeFilter(v)} + > + All + Live + Post + + + + {loadingData ? ( + + ) : !responses.length ? ( + No survey responses for this event yet. + ) : ( + <> + {/* ---------------------------------------------------------- KPIs */} + + + + {filtered.length} + {summary && ( + + {summary.by_mode?.live || 0} live · {summary.by_mode?.post || 0} post + + )} + + + + + {agg.scale.overall_rating?.count ? ( + <> + + {(agg.scale.overall_rating.sum / agg.scale.overall_rating.count).toFixed(2)} + + + + ) : ( + + )} + + + + + + {agg.scale.would_return?.count + ? (agg.scale.would_return.sum / agg.scale.would_return.count).toFixed(2) + : "—"} + + {agg.scale.would_return?.count ? ( + / 5 + ) : null} + + + + + + {Object.entries( + filtered.reduce((acc, r) => { + acc[r.role] = (acc[r.role] || 0) + 1; + return acc; + }, {}) + ).map(([role, n]) => ( + + ))} + + + + + + {/* ------------------------------------------- during-event pulse */} + + + During-event pulse (live responses) + {liveCount === 0 || pulseSeries.length === 0 ? ( + No live (during-event) responses for this event — the pulse needs feedback submitted while the event is running. + ) : ( + <> + + + + + + + + `Hour ${h} since kickoff`} /> + + + + + {pulseSeries.map((b) => ( + + ))} + + + + + + + Hours since kickoff. Bars = live responses (grey = small sample, n<3); line = avg rating. Dashed lines mark day boundaries. + + + )} + + + + {/* ----------------------------------------------- needs attention */} + {flags.length > 0 && ( + + + + + Needs attention ({flags.length}) + + } spacing={1}> + {flags.map((r) => ( + + + + + {r.answers?.overall_rating != null && ( + + )} + {fmtDate(r.created_at)} + + + {r.reasons.map((reason, i) => ( + + ))} + + {gist(r) && “{gist(r)}”} + + ))} + + + + )} + + {/* ------------------------------------------- free-text themes */} + {(goingWellThemes.length > 0 || toImproveThemes.length > 0) && ( + + + Recurring themes + + + Going well + {goingWellThemes.length ? ( + + ) : ( + No recurring terms yet. + )} + + + To improve + {toImproveThemes.length ? ( + + ) : ( + No recurring terms yet. + )} + + + + Simple term/phrase frequency (≥2 mentions). Read the verbatim quotes below for context. + + + + )} + + {/* ------------------------------------------- qualitative (the gold) */} + {textBlocks.length > 0 && ( + + + What people said + {textBlocks.map((id) => { + const q = QUESTIONS_BY_ID[id]; + const entries = agg.texts[id]; + return ( + + + {qLabel(q)} ({entries.length}) + + + {entries.map((e, i) => ( + + + {e.text} + {e.role && } + + ))} + + + ); + })} + + + )} + + {/* ----------------------------------------------- quantitative breakdown */} + {quantBlocks.length > 0 && ( + + + Question breakdown + + {quantBlocks.map((id) => { + const q = QUESTIONS_BY_ID[id]; + const s = agg.scale[id]; + const c = agg.choice[id]; + return ( + + {qLabel(q)} + {s && ( + <> + + avg {(s.sum / s.count).toFixed(2)} · n={s.count} + + + + )} + {c && } + + ); + })} + + + + )} + + {/* ------------------------------------------------ individual responses */} + Individual responses ({filtered.length}) + {filtered.map((r) => { + const answered = Object.keys(r.answers || {}) + .map((id) => QUESTIONS_BY_ID[id]) + .filter(Boolean); + return ( + + }> + + + + {r.answers?.overall_rating != null && ( + + )} + + {r.is_anonymous ? "Anonymous" : r.email || "Logged-in"} · {fmtDate(r.created_at)} + + + + + } spacing={1}> + {answered.map((q) => { + const formatted = formatAnswer(q, r.answers[q.id]); + if (formatted == null) return null; + return ( + + {qLabel(q)} + {formatted} + + ); + })} + + + + ); + })} + + )} + + )} + + ); +}; + +export default EventSurveysPanel; diff --git a/src/components/admin/feedback/OnboardingPanel.js b/src/components/admin/feedback/OnboardingPanel.js new file mode 100644 index 00000000..6a0f6290 --- /dev/null +++ b/src/components/admin/feedback/OnboardingPanel.js @@ -0,0 +1,551 @@ +import React, { useState, useEffect, useMemo, useCallback } from "react"; +import { + Box, + Grid, + Card, + CardContent, + Typography, + CircularProgress, + Chip, + Stack, + Divider, + Accordion, + AccordionSummary, + AccordionDetails, + Alert, + Rating, + LinearProgress, + ToggleButton, + ToggleButtonGroup, + Button, + Tooltip as MuiTooltip, +} from "@mui/material"; +import { + ExpandMore as ExpandMoreIcon, + ContentCopy as CopyIcon, + TrendingUp as UpIcon, + TrendingDown as DownIcon, + WarningAmber as WarningIcon, +} from "@mui/icons-material"; +import { + ComposedChart, + BarChart, + Bar, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip as RTooltip, + Legend, + ResponsiveContainer, + ReferenceLine, + Cell, +} from "recharts"; +import moment from "moment"; +import { + EASE_ORDER, + EASE_COLORS, + CLEAR_EASE, + CONFUSING_EASE, + USEFUL_TOPICS, + parseTs, + bucketKeyFor, + bucketByPeriod, + extractThemes, + parseDevice, +} from "./onboardingAnalytics"; + +const API = process.env.NEXT_PUBLIC_API_SERVER_URL; +const RANGE_DAYS = { "90d": 90, "12mo": 365, all: null }; + +const fmtDate = (iso) => { + const d = parseTs(iso); + return d ? d.toLocaleDateString() : ""; +}; +const gist = (it) => (it.missingTopics || it.improvements || it.additionalFeedback || "").trim(); + +const avgRatingBetween = (items, from, to) => { + let sum = 0; + let n = 0; + for (const it of items) { + if (typeof it.overallRating !== "number" || it.overallRating <= 0) continue; + const d = parseTs(it.timestamp); + if (!d) continue; + const m = moment(d); + if (m.isSameOrAfter(from) && m.isBefore(to)) { + sum += it.overallRating; + n += 1; + } + } + return n ? sum / n : null; +}; + +const KpiCard = ({ label, children }) => ( + + + {label} + {children} + + +); + +// Compact labeled bar (reused for rating / ease / topic distributions). +const StatBar = ({ label, value, total, color }) => { + const pct = total ? Math.round((value / total) * 100) : 0; + return ( + + + {label} + {value} ({pct}%) + + + + ); +}; + +const OnboardingPanel = ({ accessToken, orgId, onError }) => { + const [data, setData] = useState(null); + const [hackathons, setHackathons] = useState([]); + const [loading, setLoading] = useState(true); + const [granularity, setGranularity] = useState("month"); + const [range, setRange] = useState("all"); + const [easeFilter, setEaseFilter] = useState("all"); + + const headers = useMemo( + () => ({ authorization: `Bearer ${accessToken}`, "content-type": "application/json", "X-Org-Id": orgId }), + [accessToken, orgId] + ); + + useEffect(() => { + let active = true; + (async () => { + setLoading(true); + try { + const [obRes, hackRes] = await Promise.all([ + fetch(`${API}/api/admin/feedback/onboarding`, { headers }), + fetch(`${API}/api/messages/hackathons`, { headers }), + ]); + if (!obRes.ok) throw new Error("onboarding"); + const obJson = await obRes.json(); + const hackJson = hackRes.ok ? await hackRes.json() : { hackathons: [] }; + if (!active) return; + setData(obJson); + setHackathons(hackJson.hackathons || []); + } catch (e) { + if (active) onError?.("Failed to load onboarding feedback."); + } finally { + if (active) setLoading(false); + } + })(); + return () => { active = false; }; + }, [headers, onError]); + + const allItems = data?.onboarding_feedback || []; + + // Single range control drives the whole dashboard. + const items = useMemo(() => { + const days = RANGE_DAYS[range]; + if (!days) return allItems; + const cutoff = moment().subtract(days, "days"); + return allItems.filter((it) => { + const d = parseTs(it.timestamp); + return d && moment(d).isSameOrAfter(cutoff); + }); + }, [allItems, range]); + + const buckets = useMemo(() => bucketByPeriod(items, granularity), [items, granularity]); + + // 100%-stacked clarity mix per bucket (proportions, not counts). + const claritySeries = useMemo( + () => buckets.map((b) => { + const total = EASE_ORDER.reduce((s, e) => s + (b[e] || 0), 0); + const row = { label: b.label, total }; + EASE_ORDER.forEach((e) => { row[e] = total ? Math.round(((b[e] || 0) / total) * 100) : 0; }); + return row; + }), + [buckets] + ); + + // Event markers aligned to the bucket they fall in. + const eventMarkers = useMemo(() => { + const keyToLabel = new Map(buckets.map((b) => [b.key, b.label])); + const byLabel = {}; + hackathons.forEach((h) => { + const d = parseTs(h.start_date); + if (!d) return; + const label = keyToLabel.get(bucketKeyFor(d, granularity)); + if (!label) return; + (byLabel[label] = byLabel[label] || []).push(h.title); + }); + return Object.entries(byLabel).map(([label, titles]) => ({ + label, + title: titles.length > 1 ? `${titles.length} events` : titles[0], + })); + }, [hackathons, buckets, granularity]); + + // KPIs. + const rated = items.filter((it) => typeof it.overallRating === "number" && it.overallRating > 0); + const avgRating = rated.length ? rated.reduce((s, it) => s + it.overallRating, 0) / rated.length : null; + const easeAnswered = items.filter((it) => it.easeOfUnderstanding); + const clearN = easeAnswered.filter((it) => CLEAR_EASE.has(it.easeOfUnderstanding)).length; + const clearPct = easeAnswered.length ? Math.round((clearN / easeAnswered.length) * 100) : null; + const willingN = items.filter((it) => it.contact?.willing).length; + const willingPct = items.length ? Math.round((willingN / items.length) * 100) : 0; + + const ratingDelta = useMemo(() => { + const span = RANGE_DAYS[range] || 90; + const cur = avgRatingBetween(allItems, moment().subtract(span, "days"), moment()); + const prev = avgRatingBetween(allItems, moment().subtract(2 * span, "days"), moment().subtract(span, "days")); + return cur != null && prev != null ? +(cur - prev).toFixed(2) : null; + }, [allItems, range]); + + // Distributions. + const ratingDist = useMemo(() => { + const d = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 }; + rated.forEach((it) => { d[it.overallRating] = (d[it.overallRating] || 0) + 1; }); + return d; + }, [rated]); + const easeDist = useMemo(() => { + const d = {}; + EASE_ORDER.forEach((e) => { d[e] = 0; }); + easeAnswered.forEach((it) => { d[it.easeOfUnderstanding] = (d[it.easeOfUnderstanding] || 0) + 1; }); + return d; + }, [easeAnswered]); + const topicFreq = useMemo( + () => USEFUL_TOPICS.map((t) => ({ topic: t, count: items.filter((it) => (it.usefulTopics || []).includes(t)).length })), + [items] + ); + + // Action lists. + const followUps = useMemo( + () => items.filter((it) => it.contact?.willing && it.contact?.email), + [items] + ); + const needsAttention = useMemo( + () => items.filter( + (it) => (typeof it.overallRating === "number" && it.overallRating > 0 && it.overallRating <= 2) || + CONFUSING_EASE.has(it.easeOfUnderstanding) + ), + [items] + ); + const themes = useMemo( + () => extractThemes(items.flatMap((it) => [it.missingTopics, it.improvements])), + [items] + ); + + const responseList = useMemo( + () => (easeFilter === "all" ? items : items.filter((it) => it.easeOfUnderstanding === easeFilter)), + [items, easeFilter] + ); + + const copyEmails = useCallback(() => { + const emails = followUps.map((f) => f.contact.email).filter(Boolean).join(", "); + if (navigator.clipboard && emails) { + navigator.clipboard.writeText(emails); + onError?.(`Copied ${followUps.length} email${followUps.length === 1 ? "" : "s"}.`, "success"); + } + }, [followUps, onError]); + + if (loading) { + return ; + } + if (!allItems.length) { + return No onboarding feedback recorded yet.; + } + + return ( + + {/* range control (drives the whole dashboard) */} + + Window: + v && setRange(v)}> + 90 days + 12 months + All time + + + + {items.length} of {allItems.length} responses + + + + {/* ----------------------------------------------------------- KPI band */} + + + + {items.length} + {willingN} open to follow-up + + + + + + {avgRating != null ? avgRating.toFixed(2) : "—"} + {ratingDelta != null && ratingDelta !== 0 && ( + 0 ? "success.main" : "error.main" }}> + {ratingDelta > 0 ? : } + {Math.abs(ratingDelta).toFixed(2)} + + )} + + {avgRating != null && } + + + + + {clearPct != null ? `${clearPct}%` : "—"} + Very easy + Mostly clear + + + + + {willingPct}% + {willingN} of {items.length} + + + + + {/* --------------------------------------------------- trends over time */} + + + + Feedback over time + v && setGranularity(v)}> + Weekly + Monthly + + + + {buckets.length === 0 ? ( + No dated responses in this window. + ) : ( + <> + + + + + + + + + + + {buckets.map((b) => ( + + ))} + + + {eventMarkers.map((m) => ( + + ))} + + + + + Bars = response volume (grey = small sample, n<3 — interpret the rating with care). Dashed lines mark hackathon dates. + + + {/* clarity mix (proportions) */} + Clarity mix over time (% of responses by ease) + + + + + + + [`${v}%`, name]} /> + + {EASE_ORDER.map((e) => ( + + ))} + + + + + )} + + + + {/* ---------------------------------------------------- snapshot distributions */} + + + + Rating distribution + {[5, 4, 3, 2, 1].map((n) => ( + + ))} + + + + + Ease of understanding + {EASE_ORDER.map((e) => ( + + ))} + + + + + Most-cited helpful resources + {topicFreq.map(({ topic, count }) => ( + + ))} + + + + + {/* ----------------------------------------------------------- action lists */} + + + + + Follow-up queue ({followUps.length}) + {followUps.length > 0 && ( + + )} + + {followUps.length === 0 ? ( + No one opted in for follow-up in this window. + ) : ( + } spacing={1}> + {followUps.map((f) => ( + + + {f.contact.firstName || "—"} + {f.contact.email} + {f.overallRating ? : null} + + {gist(f) && “{gist(f)}”} + + ))} + + )} + + + + + + + + Needs attention ({needsAttention.length}) + + {needsAttention.length === 0 ? ( + No low-rated or confusing responses. 🎉 + ) : ( + } spacing={1}> + {needsAttention.map((it) => ( + + + {it.overallRating ? : null} + {it.easeOfUnderstanding && ( + + )} + {fmtDate(it.timestamp)} + + {gist(it) && “{gist(it)}”} + + ))} + + )} + + + + + {/* -------------------------------------------- free-text themes (keyword) */} + {themes.length > 0 && ( + + + Recurring words & phrases (missing topics + improvements) + + {themes.map((t) => ( + + + + ))} + + + Simple term/phrase frequency (≥2 mentions). Read the verbatim quotes above for context. + + + + )} + + {/* ----------------------------------------------------- all responses */} + + All responses ({responseList.length}) + v && setEaseFilter(v)}> + All + {EASE_ORDER.map((e) => ( + {e.split(" ")[0]} + ))} + + + {responseList.map((it) => { + const dev = parseDevice(it.userAgent); + return ( + + }> + + {it.overallRating ? : } + {it.easeOfUnderstanding && ( + + )} + {it.contact?.willing && it.contact?.firstName && ( + + )} + + + {fmtDate(it.timestamp)} + + + + {it.usefulTopics?.length > 0 && ( + + Useful topics + + {it.usefulTopics.map((t) => )} + + + )} + } spacing={1}> + {it.missingTopics && ( + Missing / wanted topics + {it.missingTopics} + )} + {it.improvements && ( + Suggested improvements + {it.improvements} + )} + {it.additionalFeedback && ( + Additional feedback + {it.additionalFeedback} + )} + {it.contact?.willing && it.contact?.email && ( + Follow-up contact + {it.contact.firstName} <{it.contact.email}> + )} + + + + ); + })} + + ); +}; + +export default OnboardingPanel; diff --git a/src/components/admin/feedback/PeerFeedbackPanel.js b/src/components/admin/feedback/PeerFeedbackPanel.js new file mode 100644 index 00000000..e2739291 --- /dev/null +++ b/src/components/admin/feedback/PeerFeedbackPanel.js @@ -0,0 +1,201 @@ +import React, { useState, useEffect, useMemo } from "react"; +import { + Box, + Card, + CardContent, + Typography, + CircularProgress, + Chip, + Stack, + Divider, + Accordion, + AccordionSummary, + AccordionDetails, + Alert, + TextField, + InputAdornment, + Avatar, + LinearProgress, + Grid, +} from "@mui/material"; +import { + ExpandMore as ExpandMoreIcon, + Search as SearchIcon, + ArrowForward as ArrowIcon, +} from "@mui/icons-material"; + +const API = process.env.NEXT_PUBLIC_API_SERVER_URL; + +// Inside the feedback map, numeric values are 0-100 skill scores and string +// values (e.g. "Overall Performance", "Areas for Improvement") are free-text. +// `role` is metadata and rendered separately. +const fmtDate = (iso) => { + if (!iso) return ""; + const d = new Date(iso); + return isNaN(d) ? "" : d.toLocaleDateString(); +}; + +const ScoreRow = ({ label, value }) => ( + + + {label} + {value} + + + +); + +const Person = ({ person, fallback }) => ( + + + {(person?.name || fallback || "?").charAt(0)} + + {person?.name || fallback} + +); + +const PeerFeedbackPanel = ({ accessToken, orgId, onError }) => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); + + useEffect(() => { + let active = true; + (async () => { + setLoading(true); + try { + const res = await fetch(`${API}/api/admin/feedback/peer`, { + headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json", "X-Org-Id": orgId }, + }); + if (!res.ok) throw new Error("peer feedback"); + const json = await res.json(); + if (active) setData(json); + } catch (e) { + if (active) onError?.("Failed to load peer feedback."); + } finally { + if (active) setLoading(false); + } + })(); + return () => { active = false; }; + }, [accessToken, orgId, onError]); + + const items = data?.feedback || []; + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return items; + return items.filter((f) => { + const names = [f.receiver?.name, f.giver?.name, f.relationship, f.role] + .filter(Boolean) + .join(" ") + .toLowerCase(); + return names.includes(q); + }); + }, [items, search]); + + if (loading) { + return ; + } + if (!items.length) { + return No peer-to-peer feedback recorded yet.; + } + + const byRel = data?.summary?.by_relationship || {}; + const byRole = data?.summary?.by_role || {}; + + return ( + + + + + Total + {items.length} + + + + + By relationship + + {Object.entries(byRel).map(([k, n]) => )} + + + + + + By role + + {Object.entries(byRole).map(([k, n]) => )} + + + + + + setSearch(e.target.value)} + sx={{ mb: 2 }} + InputProps={{ startAdornment: }} + /> + + {filtered.map((f) => { + const fb = f.feedback || {}; + const scores = Object.entries(fb).filter( + ([k, v]) => k !== "role" && typeof v === "number" + ); + const texts = Object.entries(fb).filter( + ([k, v]) => k !== "role" && typeof v === "string" && v.trim() + ); + return ( + + }> + + + + + {f.role && } + {f.relationship && } + + {fmtDate(f.timestamp)} + + + + + {f.is_anonymous && } + {f.confidence_level && } + {f.duration && } + + + {scores.length > 0 && ( + + Skill scores + {scores.map(([k, v]) => )} + + )} + {texts.length > 0 && ( + + } spacing={1}> + {texts.map(([k, v]) => ( + + {k} + {v} + + ))} + + + )} + + + + ); + })} + + ); +}; + +export default PeerFeedbackPanel; diff --git a/src/components/admin/feedback/onboardingAnalytics.js b/src/components/admin/feedback/onboardingAnalytics.js new file mode 100644 index 00000000..fb564bff --- /dev/null +++ b/src/components/admin/feedback/onboardingAnalytics.js @@ -0,0 +1,162 @@ +// Pure analytics helpers for the admin Onboarding feedback dashboard. +// Kept dependency-light (only moment, already in the bundle) and side-effect +// free so the panel stays readable and these can be unit-tested in isolation. +import moment from "moment"; + +// easeOfUnderstanding is an ordinal scale (best -> worst). Colors go green -> red. +export const EASE_ORDER = [ + "Very easy", + "Mostly clear", + "Somewhat confusing", + "Very difficult", +]; +export const EASE_COLORS = { + "Very easy": "#2e7d32", + "Mostly clear": "#9ccc65", + "Somewhat confusing": "#ffa726", + "Very difficult": "#e53935", +}; +// "clear" = the two positive ends of the scale. +export const CLEAR_EASE = new Set(["Very easy", "Mostly clear"]); +// The confusing end — used to flag responses that need attention. +export const CONFUSING_EASE = new Set(["Somewhat confusing", "Very difficult"]); + +// The fixed onboarding-topic options (stable order for the frequency bar). +export const USEFUL_TOPICS = [ + "Mission Overview", + "Introduction Guide", + "Slack Tutorial", + "Buddy System", + "FAQ Section", +]; + +const TS_PREFIX = "__Timestamp__"; + +/** Parse an API timestamp into a Date (tolerates a stray __Timestamp__ prefix). */ +export function parseTs(iso) { + if (!iso) return null; + let s = iso; + if (typeof s === "string" && s.startsWith(TS_PREFIX)) s = s.slice(TS_PREFIX.length); + const d = new Date(s); + return isNaN(d.getTime()) ? null : d; +} + +/** moment bucket key (start of week/month), 'YYYY-MM-DD'. */ +export function bucketKeyFor(date, granularity) { + if (!date) return null; + return moment(date).startOf(granularity).format("YYYY-MM-DD"); +} + +const bucketLabel = (key, granularity) => + granularity === "month" + ? moment(key).format("MMM YYYY") + : moment(key).format("MMM D"); + +/** + * Group responses into a CONTINUOUS series of week/month buckets (empty buckets + * are zero-filled so a gap reads as "no responses", not a compressed axis). + * Each bucket: { key, label, count, avgRating, lowN, ...easeCategoryCounts }. + * Ease category counts are spread as top-level keys so recharts can stack them. + */ +export function bucketByPeriod(items, granularity) { + const dated = items + .map((it) => ({ it, date: parseTs(it.timestamp) })) + .filter((x) => x.date); + if (!dated.length) return []; + + const map = new Map(); + let min = null; + let max = null; + for (const { it, date } of dated) { + const key = bucketKeyFor(date, granularity); + if (!map.has(key)) { + const b = { key, label: bucketLabel(key, granularity), count: 0, _ratingSum: 0, _ratingN: 0 }; + EASE_ORDER.forEach((e) => { b[e] = 0; }); + map.set(key, b); + } + const b = map.get(key); + b.count += 1; + if (typeof it.overallRating === "number" && it.overallRating > 0) { + b._ratingSum += it.overallRating; + b._ratingN += 1; + } + if (it.easeOfUnderstanding && b[it.easeOfUnderstanding] !== undefined) { + b[it.easeOfUnderstanding] += 1; + } + const m = moment(key); + if (!min || m.isBefore(min)) min = m.clone(); + if (!max || m.isAfter(max)) max = m.clone(); + } + + // Zero-fill the continuous range. + const out = []; + const cursor = min.clone(); + while (cursor.isSameOrBefore(max)) { + const key = cursor.format("YYYY-MM-DD"); + const b = map.get(key) || (() => { + const empty = { key, label: bucketLabel(key, granularity), count: 0, _ratingSum: 0, _ratingN: 0 }; + EASE_ORDER.forEach((e) => { empty[e] = 0; }); + return empty; + })(); + b.avgRating = b._ratingN ? +(b._ratingSum / b._ratingN).toFixed(2) : null; + b.lowN = b.count > 0 && b.count < 3; + out.push(b); + cursor.add(1, granularity); + } + return out; +} + +// Compact stopword list for keyword/bigram theming of short free-text answers. +const STOPWORDS = new Set( + ("a an the and or but if then to of in on for with about into from by at as is are was were be been being " + + "it its this that these those i you we they he she them us our your my me more most some any no not yes " + + "can could should would will just very really also too so than there their what which who how when where why " + + "do does did done have has had get got make made like want need think feel know thing things lot bit etc " + + "good great nice okay ok pretty little maybe yeah was were am").split(/\s+/) +); + +const tokenize = (text) => + (text || "") + .toLowerCase() + .replace(/[^a-z0-9\s]/g, " ") + .split(/\s+/) + .filter((w) => w.length >= 3 && !STOPWORDS.has(w)); + +/** + * Surface recurring themes from a list of free-text strings: top unigrams and + * bigrams by frequency. Transparent and inspectable — no external NLP. + * Returns [{ term, count }] sorted desc, capped at `top`. + */ +export function extractThemes(texts, top = 12) { + const counts = new Map(); + const bump = (term) => counts.set(term, (counts.get(term) || 0) + 1); + for (const text of texts) { + const toks = tokenize(text); + toks.forEach((t) => bump(t)); + for (let i = 0; i < toks.length - 1; i++) bump(`${toks[i]} ${toks[i + 1]}`); + } + return [...counts.entries()] + .filter(([, n]) => n >= 2) // a theme needs at least two mentions + .map(([term, count]) => ({ term, count })) + .sort((a, b) => b.count - a.count) + .slice(0, top); +} + +/** Coarse {browser, os, label} from a user-agent string (no UA-parser dep). */ +export function parseDevice(ua) { + if (!ua) return { browser: "", os: "", label: "Unknown" }; + let browser = "Browser"; + if (/edg/i.test(ua)) browser = "Edge"; + else if (/chrome|crios/i.test(ua)) browser = "Chrome"; + else if (/firefox|fxios/i.test(ua)) browser = "Firefox"; + else if (/safari/i.test(ua)) browser = "Safari"; + + let os = ""; + if (/iphone|ipad|ipod/i.test(ua)) os = "iOS"; + else if (/android/i.test(ua)) os = "Android"; + else if (/mac os x/i.test(ua)) os = "macOS"; + else if (/windows/i.test(ua)) os = "Windows"; + else if (/linux/i.test(ua)) os = "Linux"; + + return { browser, os, label: [browser, os].filter(Boolean).join(" · ") || "Unknown" }; +} diff --git a/src/components/admin/feedback/surveyAnalytics.js b/src/components/admin/feedback/surveyAnalytics.js new file mode 100644 index 00000000..8090269f --- /dev/null +++ b/src/components/admin/feedback/surveyAnalytics.js @@ -0,0 +1,264 @@ +// Pure analytics helpers for the admin Event Surveys dashboard. +// +// Dependency-light (only moment, already bundled) and side-effect free so the +// panels stay readable and these can be unit-tested in isolation. The typed +// aggregation + answer formatting were lifted out of EventSurveysPanel so both +// the single-event view and the cross-event view share one implementation. +import moment from "moment"; +import { parseTs, extractThemes } from "./onboardingAnalytics"; +import { SURVEY_QUESTIONS, questionLabel, ROLE_OPTIONS } from "../../Survey/surveyQuestions"; + +export { parseTs, extractThemes }; + +export const QUESTIONS_BY_ID = Object.fromEntries(SURVEY_QUESTIONS.map((q) => [q.id, q])); +// Stable catalog order so question blocks render universal-first, then by role. +export const QUESTION_ORDER = SURVEY_QUESTIONS.map((q) => q.id); +export const ROLE_LABEL = Object.fromEntries(ROLE_OPTIONS.map((r) => [r.value, r.label])); + +// Consistent per-role coloring and a distinct palette for overlaying series. +export const ROLE_COLORS = { + hacker: "#1e88e5", + mentor: "#8e24aa", + judge: "#fb8c00", + nonprofit: "#43a047", + volunteer: "#00897b", + sponsor: "#6d4c41", + organizer: "#e53935", +}; +export const SERIES_COLORS = [ + "#1e88e5", "#e53935", "#43a047", "#fb8c00", "#8e24aa", "#00897b", "#6d4c41", "#c0ca33", +]; + +/** Resolve the visible label for a question (prefer post, fall back to live/id). */ +export const qLabel = (q) => + questionLabel(q, "post") || questionLabel(q, "live") || q?.label || q?.id; + +// --------------------------------------------------------------- aggregation +/** Typed roll-up of a response set: scale dists, choice counts, free text. */ +export function aggregate(responses) { + const scale = {}; // id -> { sum, count, dist: {1..5} } + const choice = {}; // id -> { counts: {opt: n}, total } + const texts = {}; // id -> [{ text, role }] + + const addScale = (id, v) => { + if (typeof v !== "number") return; + const s = (scale[id] = scale[id] || { sum: 0, count: 0, dist: {} }); + s.sum += v; + s.count += 1; + s.dist[v] = (s.dist[v] || 0) + 1; + }; + const addChoice = (id, v) => { + if (v === null || v === undefined || v === "") return; + const c = (choice[id] = choice[id] || { counts: {}, total: 0 }); + c.counts[v] = (c.counts[v] || 0) + 1; + c.total += 1; + }; + const addText = (id, t, role) => { + if (!t || typeof t !== "string" || !t.trim()) return; + (texts[id] = texts[id] || []).push({ text: t.trim(), role }); + }; + + (responses || []).forEach((r) => { + const role = r.role; + const answers = r.answers || {}; + Object.entries(answers).forEach(([id, val]) => { + const q = QUESTIONS_BY_ID[id]; + if (!q) return; // skip the mirrored `role` key and anything off-catalog + switch (q.type) { + case "scale": + addScale(id, val); + break; + case "scale_text": + if (val && typeof val === "object") { + addScale(id, val.value); + addText(id, val.note, role); + } + break; + case "single": + case "yesno": + case "yesnomaybe": + addChoice(id, val); + break; + case "yesno_text": + if (val && typeof val === "object") { + addChoice(id, val.value); + addText(id, val.note, role); + } + break; + case "multi": + (Array.isArray(val) ? val : []).forEach((opt) => addChoice(id, opt)); + break; + case "text": + addText(id, val, role); + break; + default: + break; + } + }); + }); + return { scale, choice, texts }; +} + +/** Format a single answer for the per-response detail view. */ +export function formatAnswer(q, val) { + if (val === null || val === undefined || val === "") return null; + switch (q.type) { + case "scale": + return `${val} / 5`; + case "scale_text": + return typeof val === "object" + ? `${val.value ?? "—"} / 5${val.note ? ` — “${val.note}”` : ""}` + : null; + case "yesno_text": + return typeof val === "object" + ? `${val.value ?? "—"}${val.note ? ` — “${val.note}”` : ""}` + : null; + case "multi": + return Array.isArray(val) ? val.join(", ") : null; + default: + return String(val); + } +} + +// --------------------------------------------------------- time-over-event +/** + * Bucket responses by elapsed hour since the event kickoff (event-local). + * Continuous + zero-filled so quiet hours read as quiet. Powers the + * single-event "live pulse" and the cross-event elapsed overlay. + * Each bucket: { hour, count, avgRating, lowN }. + */ +export function elapsedHourSeries(responses, startISO, { liveOnly = true, maxHours } = {}) { + const considered = (responses || []).filter((r) => (liveOnly ? r.mode === "live" : true)); + const timed = considered + .map((r) => ({ r, t: parseTs(r.created_at) })) + .filter((x) => x.t); + if (!timed.length) return []; + + // Prefer the event's start; fall back to the earliest response in the set. + // A date-only start (YYYY-MM-DD) is anchored at LOCAL midnight (not UTC) so + // "hour 0" lines up with the kickoff day for same-timezone admins. + let base = + typeof startISO === "string" && /^\d{4}-\d{2}-\d{2}$/.test(startISO) + ? moment(startISO, "YYYY-MM-DD").toDate() + : parseTs(startISO); + if (!base) base = timed.reduce((min, x) => (x.t < min ? x.t : min), timed[0].t); + + const map = new Map(); + let observedMax = 0; + for (const { r, t } of timed) { + const h = Math.floor((t.getTime() - base.getTime()) / 3600000); + if (h < 0) continue; // before kickoff — ignore + if (h > observedMax) observedMax = h; + let b = map.get(h); + if (!b) { + b = { count: 0, _sum: 0, _n: 0 }; + map.set(h, b); + } + b.count += 1; + const rating = r.answers?.overall_rating; + if (typeof rating === "number") { + b._sum += rating; + b._n += 1; + } + } + + // Span at least the event duration (if known) but include any late responses. + const limit = maxHours != null ? Math.max(maxHours, observedMax) : observedMax; + const out = []; + for (let h = 0; h <= limit; h++) { + const b = map.get(h) || { count: 0, _sum: 0, _n: 0 }; + out.push({ + hour: h, + count: b.count, + avgRating: b._n ? +(b._sum / b._n).toFixed(2) : null, + lowN: b.count > 0 && b.count < 3, + }); + } + return out; +} + +/** Hours between an event's start and (inclusive) end day, rounded up, or null. */ +export function eventDurationHours(startISO, endISO) { + if (!startISO || !endISO) return null; + const dateOnly = (v) => /^\d{4}-\d{2}-\d{2}$/.test(v); + const s = dateOnly(startISO) ? moment(startISO, "YYYY-MM-DD") : moment(parseTs(startISO)); + const e = dateOnly(endISO) ? moment(endISO, "YYYY-MM-DD") : moment(parseTs(endISO)); + if (!s.isValid() || !e.isValid()) return null; + // end_date is a calendar day; treat it as inclusive end-of-day. + const hrs = Math.ceil(e.endOf("day").diff(s, "hours", true)); + return hrs > 0 ? hrs : null; +} + +// --------------------------------------------------------- qualitative pulls +/** + * Split free text into positive vs improvement buckets for theming. Notes + * attached to low scores (≤3 or "No") read as improvement signals. + */ +export function collectFreeText( + responses, + { positiveIds = ["going_well"], negativeIds = ["to_improve"] } = {} +) { + const positive = []; + const negative = []; + const pull = (val) => { + if (typeof val === "string") return val.trim(); + if (val && typeof val === "object" && typeof val.note === "string") return val.note.trim(); + return ""; + }; + for (const r of responses || []) { + const answers = r.answers || {}; + positiveIds.forEach((id) => { + const t = pull(answers[id]); + if (t) positive.push(t); + }); + negativeIds.forEach((id) => { + const t = pull(answers[id]); + if (t) negative.push(t); + }); + for (const [id, val] of Object.entries(answers)) { + const q = QUESTIONS_BY_ID[id]; + if (!q || (q.type !== "scale_text" && q.type !== "yesno_text")) continue; + if (val && typeof val === "object" && val.note) { + const low = (typeof val.value === "number" && val.value <= 3) || val.value === "No"; + if (low && !negativeIds.includes(id)) negative.push(String(val.note).trim()); + } + } + } + return { positive, negative }; +} + +// --------------------------------------------------------------- red flags +// Live-distress answers worth surfacing to organizers in real time. +const LIVE_DISTRESS = [ + { id: "hacker_blocked", bad: (v) => typeof v === "string" && v && v !== "Not blocked", reason: (v) => `Blocked: ${v}` }, + { id: "mentor_unreachable", bad: (v) => v === "Yes", reason: () => "Couldn't reach a mentor" }, + { id: "npo_team_waiting", bad: (v) => v === "Yes", reason: () => "A team is waiting on this nonprofit" }, + { id: "mentor_team_concern", bad: (v) => typeof v === "string" && v.trim(), reason: (v) => `Worried about a team: “${v.trim()}”` }, + { id: "vol_live_issue", bad: (v) => typeof v === "string" && v.trim(), reason: (v) => `Live issue: “${v.trim()}”` }, +]; + +/** Responses needing attention: low overall rating + live-distress signals. */ +export function redFlags(responses) { + const flagged = []; + for (const r of responses || []) { + const answers = r.answers || {}; + const reasons = []; + const overall = answers.overall_rating; + if (typeof overall === "number" && overall > 0 && overall <= 2) { + reasons.push(`Low rating (${overall}/5)`); + } + for (const rule of LIVE_DISTRESS) { + const v = answers[rule.id]; + if (rule.bad(v)) reasons.push(rule.reason(v)); + } + if (reasons.length) flagged.push({ ...r, reasons }); + } + flagged.sort((a, b) => String(b.created_at || "").localeCompare(String(a.created_at || ""))); + return flagged; +} + +/** One-line gist of a response for compact lists. */ +export function gist(r) { + const a = r.answers || {}; + return (a.to_improve || a.going_well || "").toString().trim(); +} diff --git a/src/components/admin/hackathon-edit/HackathonAdminLayout.js b/src/components/admin/hackathon-edit/HackathonAdminLayout.js new file mode 100644 index 00000000..7259addd --- /dev/null +++ b/src/components/admin/hackathon-edit/HackathonAdminLayout.js @@ -0,0 +1,189 @@ +import React from "react"; +import { + Box, + Button, + Chip, + Divider, + IconButton, + List, + ListItemButton, + ListItemIcon, + ListItemText, + Stack, + Tooltip, + Typography, + Drawer, + useMediaQuery, +} from "@mui/material"; +import { + ArrowBack as BackIcon, + Launch as LaunchIcon, + Menu as MenuIcon, + FiberManualRecord as DotIcon, +} from "@mui/icons-material"; +import { useTheme } from "@mui/material/styles"; +import { SECTIONS } from "./sectionsManifest"; +import SaveIndicator from "./SaveIndicator"; + +const SIDEBAR_WIDTH = 240; + +const SidebarContent = ({ activeSection, onPick, dirtySections }) => { + const dirtyByKey = (slug) => { + // An explicit-save section is "dirty" when its name is in dirtySections. + return dirtySections?.has(slug); + }; + + // Group by manifest group; default to "config" for any unlabeled (legacy) + // entries. The order of groups here defines the order in the sidebar. + const groupOrder = ["config", "ops"]; + const groupLabels = { config: "Configure", ops: "Operate" }; + const grouped = groupOrder.map((g) => [g, SECTIONS.filter((s) => (s.group || "config") === g)]); + + const renderItem = ({ slug, label, icon: Icon }) => { + const active = activeSection === slug; + const dirty = dirtyByKey(slug); + return ( + onPick(slug)} + sx={{ + mx: 1, + borderRadius: 1, + my: 0.25, + "&.Mui-selected": { bgcolor: "primary.50", color: "primary.main", "& .MuiListItemIcon-root": { color: "primary.main" } }, + }} + > + + + + + {dirty && ( + + + + )} + + ); + }; + + return ( + + {grouped.map(([groupKey, items], idx) => ( + items.length > 0 && ( + + + {groupLabels[groupKey] || groupKey} + + {items.map(renderItem)} + + ) + ))} + + ); +}; + +const HackathonAdminLayout = ({ + hackathon, + activeSection, + onSelectSection, + saveState, + dirtySections, + onBack, + children, +}) => { + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("md")); + const [drawerOpen, setDrawerOpen] = React.useState(false); + + const eventId = hackathon?.event_id; + const title = hackathon?.title || (eventId ? eventId : "Hackathon"); + + return ( + + {/* Sidebar */} + {isSmall ? ( + setDrawerOpen(false)}> + { + onSelectSection(s); + setDrawerOpen(false); + }} + dirtySections={dirtySections} + /> + + ) : ( + + + + )} + + {/* Main column */} + + {/* Header */} + + {isSmall && ( + setDrawerOpen(true)} aria-label="Open sections"> + + + )} + + + + + + + + {title} + + {eventId && } + + + + {eventId && ( + + )} + + + + {/* Content — width contract: + - `overflowY: auto` + `scrollbarGutter: stable` keep width identical between + scrollable and non-scrollable sections (no ~17px scrollbar jitter). + - `overflowX: hidden` + `minWidth: 0` are critical: MUI Grid container's + negative margins (e.g. `spacing={3}` in MealsSection) extend the grid + past its parent's content area. Without these, sections containing + `` visibly bleed wider than sections built + with `` (e.g. ScheduleSection). Clipping here pins every section + to the same outer width. */} + {children} + + + ); +}; + +export default HackathonAdminLayout; diff --git a/src/components/admin/hackathon-edit/SaveIndicator.js b/src/components/admin/hackathon-edit/SaveIndicator.js new file mode 100644 index 00000000..78a4a5ba --- /dev/null +++ b/src/components/admin/hackathon-edit/SaveIndicator.js @@ -0,0 +1,59 @@ +import React, { useEffect, useState } from "react"; +import { Box, Chip, Tooltip } from "@mui/material"; +import { + CloudDone as SavedIcon, + CloudSync as SavingIcon, + CloudOff as ErrorIcon, + PauseCircleOutline as PausedIcon, +} from "@mui/icons-material"; +import { SECTION_LABELS } from "./useHackathonAdmin"; + +const formatRelative = (ts) => { + if (!ts) return ""; + const secs = Math.max(1, Math.round((Date.now() - ts) / 1000)); + if (secs < 60) return `${secs}s ago`; + const mins = Math.round(secs / 60); + if (mins < 60) return `${mins}m ago`; + return new Date(ts).toLocaleTimeString(); +}; + +const SaveIndicator = ({ saveState, dirtySections }) => { + // Re-render every 15s so "Saved · 12s ago" stays current. + const [, force] = useState(0); + useEffect(() => { + const t = setInterval(() => force((n) => n + 1), 15000); + return () => clearInterval(t); + }, []); + + if (dirtySections && dirtySections.size > 0) { + const labels = Array.from(dirtySections).map((s) => SECTION_LABELS[s] || s).join(", "); + return ( + + } + label={`Unsaved · ${labels}`} + color="warning" + size="small" + variant="outlined" + /> + + ); + } + + if (saveState.status === "saving") { + return } label="Saving…" size="small" color="info" variant="outlined" />; + } + if (saveState.status === "error") { + return ( + + } label="Save failed" size="small" color="error" /> + + ); + } + if (saveState.status === "saved" && saveState.lastSavedAt) { + return } label={`Saved · ${formatRelative(saveState.lastSavedAt)}`} size="small" color="success" variant="outlined" />; + } + return ; +}; + +export default SaveIndicator; diff --git a/src/components/admin/hackathon-edit/SectionContainer.js b/src/components/admin/hackathon-edit/SectionContainer.js new file mode 100644 index 00000000..376d8d0f --- /dev/null +++ b/src/components/admin/hackathon-edit/SectionContainer.js @@ -0,0 +1,107 @@ +import React from "react"; +import { Box, Button, Paper, Stack, Typography } from "@mui/material"; + +/** + * Standardized wrapper for every section. Renders the title, optional + * description, then the section's content. Sections that need explicit Save + * pass `dirty`, `onSave`, and `onDiscard`; those render a sticky bottom bar. + */ +const SectionContainer = ({ + title, + description, + actions, + children, + dirty = false, + saving = false, + onSave, + onDiscard, + saveLabel = "Save changes", + // When true, drops the Paper's internal padding so heavy content + // (Tabs, workbenches with their own Paper elements) renders flush + // inside the same outer frame. The border + width contract stay + // so every section has the same outer width regardless of variant. + disableGutters = false, +}) => { + const hasHeader = !!(title || description); + return ( + + {hasHeader && ( + + {title && ( + + {title} + {actions && {actions}} + + )} + {description && ( + + {description} + + )} + + )} + {children} + + {(dirty || saving) && onSave && ( + + {onDiscard && ( + + )} + + + )} + + ); +}; + +export default SectionContainer; diff --git a/src/components/admin/hackathon-edit/catalog/MenuCatalogPicker.js b/src/components/admin/hackathon-edit/catalog/MenuCatalogPicker.js new file mode 100644 index 00000000..4d2f0944 --- /dev/null +++ b/src/components/admin/hackathon-edit/catalog/MenuCatalogPicker.js @@ -0,0 +1,419 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { + Alert, + Box, + Button, + Checkbox, + Chip, + Collapse, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + FormControl, + IconButton, + InputAdornment, + InputLabel, + MenuItem, + OutlinedInput, + Paper, + Select, + Stack, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import { + Add as AddIcon, + Close as CloseIcon, + Delete as DeleteIcon, + RestaurantMenu as MenuIconSvg, + Search as SearchIcon, +} from "@mui/icons-material"; +import { + addUserCatalogItem, + getCategoriesForVendor, + getCombinedCatalog, + getVendors, + isSeededItem, + removeUserCatalogItem, +} from "./catalogStorage"; +import { formatUSD } from "./formatCurrency"; + +const ALLOWED_DIETARY_TAGS = [ + "vegetarian", + "vegan", + "halal", + "kosher", + "gluten-free", + "dairy-free", + "nut-free", + "pescatarian", +]; + +const newId = () => `user-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + +const blankCustomItem = (vendor) => ({ + id: newId(), + vendor: vendor || "", + category: "", + name: "", + description: "", + price_cents: 0, + unit: "per_person", + min_quantity: 15, + dietary_tags: [], +}); + +const AddCustomForm = ({ vendor, onAdd, onCancel }) => { + const [draft, setDraft] = useState(blankCustomItem(vendor)); + const set = (field, value) => setDraft((d) => ({ ...d, [field]: value })); + const valid = draft.name.trim() && draft.vendor.trim() && draft.price_cents >= 0; + + const handleSave = () => { + addUserCatalogItem({ + ...draft, + name: draft.name.trim(), + vendor: draft.vendor.trim(), + category: draft.category.trim() || "Custom", + description: draft.description.trim() || undefined, + }); + onAdd(); + }; + + return ( + + + New catalog item (saves to your browser) + + + + set("name", e.target.value)} + required + /> + set("vendor", e.target.value)} + required + placeholder="e.g. Local Pizza Co" + /> + + + set("category", e.target.value)} + placeholder="Lunch, Dinner, Snack…" + /> + { + const dollars = parseFloat(e.target.value); + set("price_cents", Number.isFinite(dollars) ? Math.round(dollars * 100) : 0); + }} + inputProps={{ min: 0, step: 0.25 }} + sx={{ width: 140 }} + /> + + Unit + + + + set("description", e.target.value)} + /> + + Dietary tags + + + + + + + + + ); +}; + +const MenuCatalogPicker = ({ open, onClose, onAddItems, headcount }) => { + const [catalog, setCatalog] = useState(() => getCombinedCatalog()); + const [vendors, setVendors] = useState(() => getVendors()); + const [vendorFilter, setVendorFilter] = useState(""); + const [categoryFilter, setCategoryFilter] = useState(""); + const [search, setSearch] = useState(""); + const [selected, setSelected] = useState(new Set()); + const [showAdd, setShowAdd] = useState(false); + + // Refresh catalog whenever the dialog is opened so newly-added user items + // show up immediately. + useEffect(() => { + if (open) { + setCatalog(getCombinedCatalog()); + setVendors(getVendors()); + setSelected(new Set()); + setShowAdd(false); + } + }, [open]); + + const refresh = () => { + setCatalog(getCombinedCatalog()); + setVendors(getVendors()); + setShowAdd(false); + }; + + const categories = useMemo(() => { + if (!vendorFilter) { + const all = new Set(); + catalog.forEach((it) => it.category && all.add(it.category)); + return Array.from(all); + } + return getCategoriesForVendor(vendorFilter); + }, [vendorFilter, catalog]); + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + return catalog.filter((it) => { + if (vendorFilter && it.vendor !== vendorFilter) return false; + if (categoryFilter && it.category !== categoryFilter) return false; + if (q && !`${it.name} ${it.description || ""}`.toLowerCase().includes(q)) return false; + return true; + }); + }, [catalog, vendorFilter, categoryFilter, search]); + + const toggleOne = (id) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const handleAdd = () => { + const items = catalog + .filter((it) => selected.has(it.id)) + .map((it) => ({ + id: `m-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`, + name: it.name, + description: it.description || "", + dietary_tags: it.dietary_tags || [], + price_cents: it.price_cents, + unit: it.unit || "per_person", + vendor: it.vendor, + catalog_item_id: it.id, + ...(it.unit === "each" ? { quantity: 1 } : {}), + })); + onAddItems(items); + onClose(); + }; + + const handleDeleteUserItem = (id) => { + if (!window.confirm("Remove this catalog item from your saved list?")) return; + removeUserCatalogItem(id); + refresh(); + }; + + return ( + + + + Browse menu catalog + + + + + + + + + setSearch(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + Vendor + + + + Category + + + + + {headcount > 0 && ( + + Headcount: {headcount} — per-person prices are multiplied by this when added to a meal. + + )} + + + + + + setShowAdd(false)} /> + + + + + + + {filtered.length === 0 ? ( + + No items match. Adjust filters or add a custom item. + + ) : ( + + {filtered.map((it) => { + const checked = selected.has(it.id); + const userOwned = !isSeededItem(it.id); + return ( + toggleOne(it.id)} + sx={{ + p: 1.5, + cursor: "pointer", + borderColor: checked ? "primary.main" : "divider", + bgcolor: checked ? "primary.50" : "background.paper", + transition: "border-color 80ms", + }} + > + + e.stopPropagation()} onChange={() => toggleOne(it.id)} /> + + + {it.name} + + {(it.dietary_tags || []).map((t) => ( + + ))} + {userOwned && } + + {it.description && ( + + {it.description} + + )} + + {it.vendor} + {it.min_quantity ? ` · ${it.min_quantity}-person min` : ""} + + + + + {formatUSD(it.price_cents)} + + + {it.unit === "per_person" ? "/ person" : it.unit === "each" ? "each" : "fixed"} + + {it.unit === "per_person" && headcount > 0 && ( + + = {formatUSD((it.price_cents || 0) * headcount)} + + )} + {userOwned && ( + + { + e.stopPropagation(); + handleDeleteUserItem(it.id); + }} + > + + + + )} + + + + ); + })} + + )} + + + + + + + + ); +}; + +export default MenuCatalogPicker; diff --git a/src/components/admin/hackathon-edit/catalog/catalogStorage.js b/src/components/admin/hackathon-edit/catalog/catalogStorage.js new file mode 100644 index 00000000..15fedbbe --- /dev/null +++ b/src/components/admin/hackathon-edit/catalog/catalogStorage.js @@ -0,0 +1,69 @@ +// localStorage-backed user catalog. Lives client-side until we decide to +// promote it to the backend. Keyed per-vendor so admins can keep different +// vendors' menus separate. +// +// Shape per stored item (matches FAT_FREDDYS_CATALOG entries): +// { id, vendor, category, name, description?, price_cents, unit, min_quantity?, dietary_tags[] } + +import { FAT_FREDDYS_CATALOG, FAT_FREDDYS_VENDOR, FAT_FREDDYS_CATEGORIES } from "./fatFreddysCatalog"; + +const STORAGE_KEY = "ohack_admin_menu_catalog_v1"; + +const seededFatFreddysItems = FAT_FREDDYS_CATALOG.map((it) => ({ ...it, vendor: FAT_FREDDYS_VENDOR })); + +const readStorage = () => { + if (typeof window === "undefined") return []; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch (err) { + console.warn("Failed to read catalog from localStorage:", err); + return []; + } +}; + +const writeStorage = (items) => { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); + } catch (err) { + console.warn("Failed to write catalog to localStorage:", err); + } +}; + +export const getCombinedCatalog = () => { + const userItems = readStorage(); + return [...seededFatFreddysItems, ...userItems]; +}; + +export const getVendors = () => { + const vendors = new Set([FAT_FREDDYS_VENDOR]); + readStorage().forEach((it) => it.vendor && vendors.add(it.vendor)); + return Array.from(vendors); +}; + +export const getCategoriesForVendor = (vendor) => { + if (vendor === FAT_FREDDYS_VENDOR) return FAT_FREDDYS_CATEGORIES; + const categories = new Set(); + readStorage().forEach((it) => { + if (it.vendor === vendor && it.category) categories.add(it.category); + }); + return Array.from(categories); +}; + +export const addUserCatalogItem = (item) => { + const userItems = readStorage(); + const next = [...userItems, item]; + writeStorage(next); + return next; +}; + +export const removeUserCatalogItem = (id) => { + const next = readStorage().filter((it) => it.id !== id); + writeStorage(next); + return next; +}; + +export const isSeededItem = (id) => seededFatFreddysItems.some((it) => it.id === id); diff --git a/src/components/admin/hackathon-edit/catalog/fatFreddysCatalog.js b/src/components/admin/hackathon-edit/catalog/fatFreddysCatalog.js new file mode 100644 index 00000000..e2dfbd4b --- /dev/null +++ b/src/components/admin/hackathon-edit/catalog/fatFreddysCatalog.js @@ -0,0 +1,609 @@ +// Catalog seeded from Fat Freddy's Catering menu (October 2025 v2). +// +// Source: vendor PDFs at https://www.fatfreddyscatering.com +// Prices are per-person unless `unit` says otherwise. Per-person items have a +// 15-person minimum unless specified. Many entrees come bundled with mixed +// greens salad, fresh baked bread, and chef's daily desserts — those bundled +// sides are noted in `bundled_with` for the cost-summary view. +// +// Update freely as menus change. Items are matched/identified by `id` only; +// the rest of the fields are display data and can be edited safely. + +export const FAT_FREDDYS_VENDOR = "Fat Freddy's Catering"; + +const ENTREE_BUNDLE_SALAD_BREAD_DESSERT = [ + "Mixed greens salad with dressing", + "Fresh baked bread", + "Chef's daily dessert selection", +]; + +const ENTREE_BUNDLE_BBQ = ["Two sides", "Sandwich buns or rolls", "Dessert"]; + +export const FAT_FREDDYS_CATALOG = [ + // ---- Breakfast ---- + { + id: "ff-bf-sandwiches", + category: "Breakfast", + name: "Breakfast Sandwiches", + description: + "Choose one: English Muffin with Fried Egg, Shaved Black Forest Ham & Swiss; Croissant with Fried Egg, Spicy Sausage & Swiss; or Burrito with Scrambled Egg, Chorizo, Potatoes & Cheddar.", + price_cents: 1250, + unit: "per_person", + min_quantity: 15, + dietary_tags: [], + }, + { + id: "ff-bf-continental", + category: "Breakfast", + name: "Continental Breakfast", + description: + "Assortment of sweet breads, mini muffins, danish, fresh cut fruit, and regular and/or decaf coffee.", + price_cents: 1050, + unit: "per_person", + min_quantity: 15, + dietary_tags: ["vegetarian"], + }, + { + id: "ff-bf-deluxe-continental", + category: "Breakfast", + name: "Deluxe Continental Breakfast", + description: + "Sweet breads, mini muffins, danish, bagels with cream cheese, butter & jellies, fresh cut fruit, yogurt & granola, and coffee.", + price_cents: 1600, + unit: "per_person", + min_quantity: 15, + dietary_tags: ["vegetarian"], + }, + { + id: "ff-bf-cowboy", + category: "Breakfast", + name: "Cowboy Breakfast", + description: + "Buttermilk biscuits, country sausage gravy, scrambled eggs, bacon, and fresh cut fruit.", + price_cents: 1600, + unit: "per_person", + min_quantity: 15, + dietary_tags: [], + }, + { + id: "ff-bf-pancake", + category: "Breakfast", + name: "Pancake Breakfast", + description: + "Buttermilk pancakes with butter & warm maple syrup, scrambled eggs, breakfast sausage, and fresh cut fruit.", + price_cents: 1600, + unit: "per_person", + min_quantity: 15, + dietary_tags: [], + }, + { + id: "ff-bf-brunch-starter", + category: "Breakfast", + name: "Brunch Starter", + description: + "Breakfast egg strata, caramelized french toast with butter & warm maple syrup, fresh cut fruit.", + price_cents: 1850, + unit: "per_person", + min_quantity: 15, + dietary_tags: ["vegetarian"], + }, + { + id: "ff-bf-french-toast", + category: "Breakfast", + name: "Fat Freddy's French Toast", + description: + "Thick cut caramelized french toast with butter and warm maple syrup, scrambled eggs, breakfast sausage, and fresh cut fruit.", + price_cents: 1600, + unit: "per_person", + min_quantity: 15, + dietary_tags: [], + }, + { + id: "ff-bf-ultimate", + category: "Breakfast", + name: "Ultimate Breakfast", + description: + "Scrambled eggs, apple wood smoked bacon, potatoes O'Brien, sweet breads, mini muffins, danish, bagels with cream cheese, butter & jellies, individual yogurt parfaits with granola & fresh cut fruit.", + price_cents: 1950, + unit: "per_person", + min_quantity: 15, + dietary_tags: [], + }, + { + id: "ff-bf-tacos", + category: "Breakfast", + name: "Breakfast Tacos (GF)", + description: + "Scrambled eggs, shredded cheddar, salsa, breakfast potatoes, fresh cut fruit and sausage crumble. Served with warm corn tortillas.", + price_cents: 1500, + unit: "per_person", + min_quantity: 15, + dietary_tags: ["gluten-free"], + }, + { + id: "ff-bf-omelets", + category: "Breakfast", + name: "Omelets Made to Order", + description: + "Chef-prepared personalized omelets, plus a buffet with potatoes O'Brien, apple wood smoked bacon, fresh cut fruit, and assorted sweet breads. (*) Full-Service Events only — additional labor fees apply.", + price_cents: 2500, + unit: "per_person", + min_quantity: 15, + dietary_tags: [], + }, + + // ---- All American & BBQ entrees (bundled with salad, bread, dessert) ---- + { + id: "ff-am-herb-roasted-chicken", + category: "All American & BBQ", + name: "Herb Roasted Chicken (GF)", + description: + "Fresh roasted bone-in chicken with our special blend of spices, served with red skin garlic mashed potatoes.", + price_cents: 1750, + unit: "per_person", + min_quantity: 15, + dietary_tags: ["gluten-free"], + bundled_with: ENTREE_BUNDLE_SALAD_BREAD_DESSERT, + }, + { + id: "ff-am-yankee-pot-roast", + category: "All American & BBQ", + name: "Yankee Pot Roast", + description: + "Slow roasted eye of round with pearl onions, peas and carrots. Served with homestyle mashed potatoes and brown gravy.", + price_cents: 1975, + unit: "per_person", + min_quantity: 15, + dietary_tags: [], + bundled_with: ENTREE_BUNDLE_SALAD_BREAD_DESSERT, + }, + { + id: "ff-am-meatloaf", + category: "All American & BBQ", + name: "Pan Fried Meatloaf", + description: + "75-year-old family recipe with beef and pork. Served with homestyle mashed potatoes and brown gravy.", + price_cents: 1875, + unit: "per_person", + min_quantity: 15, + dietary_tags: [], + bundled_with: ENTREE_BUNDLE_SALAD_BREAD_DESSERT, + }, + { + id: "ff-am-southern-fried-chicken", + category: "All American & BBQ", + name: "Southern Style Fried Chicken", + description: + "Crispy bone-in chicken fried in our family's secret spices. Served with homestyle mashed potatoes and creamy pepper gravy.", + price_cents: 1675, + unit: "per_person", + min_quantity: 15, + dietary_tags: [], + bundled_with: ENTREE_BUNDLE_SALAD_BREAD_DESSERT, + }, + { + id: "ff-am-burgers", + category: "All American & BBQ", + name: "1/3 Pound Angus All Beef Hamburgers", + description: + "Build-your-own condiment station: ketchup, mustard, mayo, American cheese slices, lettuce, tomato, pickle & onion. Gluten free buns at additional charge.", + price_cents: 1600, + unit: "per_person", + min_quantity: 15, + dietary_tags: [], + bundled_with: ENTREE_BUNDLE_BBQ, + }, + { + id: "ff-am-bone-in-chicken", + category: "All American & BBQ", + name: "Bone-in Chicken Quarters (GF)", + description: "Traditional sweet & smoky BBQ sauce.", + price_cents: 1695, + unit: "per_person", + min_quantity: 15, + dietary_tags: ["gluten-free"], + bundled_with: ENTREE_BUNDLE_BBQ, + }, + { + id: "ff-am-hot-dogs", + category: "All American & BBQ", + name: "1/4 Pound All Beef Hot Dogs", + description: + "Build-your-own condiment station: ketchup, mustard, chopped onions, relish. Gluten free buns at additional charge.", + price_cents: 1275, + unit: "per_person", + min_quantity: 15, + dietary_tags: [], + bundled_with: ENTREE_BUNDLE_BBQ, + }, + { + id: "ff-am-sloppy-joes", + category: "All American & BBQ", + name: "Old Fashioned Sloppy Joes", + description: "Hearty classic with beef or turkey simmered in tangy house-made tomato sauce.", + price_cents: 1275, + unit: "per_person", + min_quantity: 15, + dietary_tags: [], + bundled_with: ENTREE_BUNDLE_BBQ, + }, + { + id: "ff-am-pulled-pork", + category: "All American & BBQ", + name: "BBQ Pulled Pork", + description: "Traditional sweet & smoky BBQ sauce. Gluten free buns at additional charge.", + price_cents: 1775, + unit: "per_person", + min_quantity: 15, + dietary_tags: [], + bundled_with: ENTREE_BUNDLE_BBQ, + }, + { + id: "ff-am-pulled-chicken", + category: "All American & BBQ", + name: "BBQ Pulled Chicken", + description: "Traditional sweet & smoky BBQ sauce. Gluten free buns at additional charge.", + price_cents: 1775, + unit: "per_person", + min_quantity: 15, + dietary_tags: [], + bundled_with: ENTREE_BUNDLE_BBQ, + }, + { + id: "ff-am-brisket", + category: "All American & BBQ", + name: "BBQ Beef Brisket (GF)", + description: "Cider glazed onions & BBQ au jus.", + price_cents: 2175, + unit: "per_person", + min_quantity: 15, + dietary_tags: ["gluten-free"], + bundled_with: ENTREE_BUNDLE_BBQ, + }, + { + id: "ff-am-spare-ribs", + category: "All American & BBQ", + name: "BBQ Pork Spare Ribs (GF)", + description: "Dry rubbed with our special blend of spices.", + price_cents: 1900, + unit: "per_person", + min_quantity: 15, + dietary_tags: ["gluten-free"], + bundled_with: ENTREE_BUNDLE_BBQ, + }, + { + id: "ff-am-baby-back-ribs", + category: "All American & BBQ", + name: "BBQ Baby Back Ribs (GF)", + description: "Dry rubbed with our special blend of spices.", + price_cents: 2450, + unit: "per_person", + min_quantity: 15, + dietary_tags: ["gluten-free"], + bundled_with: ENTREE_BUNDLE_BBQ, + }, + { + id: "ff-am-baked-potato-bar", + category: "All American & BBQ", + name: "Baked Potato Bar", + description: + "Idaho baked potatoes with butter, sour cream, shredded cheddar, apple wood bacon, broccoli cheese sauce, sautéed mushrooms & green onions; mixed greens salad with fresh vegetables and dressing; fresh baked rolls & butter; cookies & brownies.", + price_cents: 1450, + unit: "per_person", + min_quantity: 15, + dietary_tags: ["vegetarian"], + }, + + // ---- International ---- + { + id: "ff-int-garlic-chicken-dijon", + category: "International", + name: "Garlic Chicken Dijon", + description: + "Dijon-marinated and pan-fried chicken breast with pesto butter sauce, served with wild rice pilaf.", + price_cents: 1950, + unit: "per_person", + min_quantity: 15, + dietary_tags: [], + bundled_with: ENTREE_BUNDLE_SALAD_BREAD_DESSERT, + }, + { + id: "ff-int-teriyaki-chicken", + category: "International", + name: "Teriyaki Chicken", + description: + "Wok tossed chicken slices with five spices and teriyaki sauce, served with basmati rice.", + price_cents: 1900, + unit: "per_person", + min_quantity: 15, + dietary_tags: [], + bundled_with: ENTREE_BUNDLE_SALAD_BREAD_DESSERT, + }, + { + id: "ff-int-apricot-mango-chicken", + category: "International", + name: "Apricot Mango Chicken", + description: + "Freddy's award-winning jerk-rub marinated grilled chicken breast with apricot coulis, served with wild rice pilaf.", + price_cents: 1950, + unit: "per_person", + min_quantity: 15, + dietary_tags: [], + bundled_with: ENTREE_BUNDLE_SALAD_BREAD_DESSERT, + }, + { + id: "ff-int-beef-stroganoff", + category: "International", + name: "Beef Stroganoff", + description: + "Tender beef in garlic-onion-sour cream-mushroom sauce, served with buttered egg noodles or homestyle mashed potatoes.", + price_cents: 1975, + unit: "per_person", + min_quantity: 15, + dietary_tags: [], + bundled_with: ENTREE_BUNDLE_SALAD_BREAD_DESSERT, + }, + { + id: "ff-int-lemon-margarita-chicken", + category: "International", + name: "Lemon Margarita Chicken", + description: + "Charred lemon chicken breast with balsamic glazed tomatoes and fresh basil, served with rice pilaf.", + price_cents: 1950, + unit: "per_person", + min_quantity: 15, + dietary_tags: [], + bundled_with: ENTREE_BUNDLE_SALAD_BREAD_DESSERT, + }, + { + id: "ff-int-white-burgundy-chicken", + category: "International", + name: "White Burgundy Chicken", + description: + "Chicken breast in white cream sauce with mushrooms, garlic, and shallots, served with red skin garlic mashed potatoes.", + price_cents: 1950, + unit: "per_person", + min_quantity: 15, + dietary_tags: [], + bundled_with: ENTREE_BUNDLE_SALAD_BREAD_DESSERT, + }, + + // ---- Italian ---- + { + id: "ff-it-white-cheddar-broccoli", + category: "Italian", + name: "White Cheddar Broccoli Bake", + description: + "Penne pasta with fresh broccoli, grilled pesto chicken, apple wood bacon and white cheddar cheese.", + price_cents: 1575, + unit: "per_person", + min_quantity: 15, + dietary_tags: [], + bundled_with: ["Mixed greens salad", "Garlic breadsticks", "Chef's dessert"], + }, + { + id: "ff-it-chicken-penne-romano", + category: "Italian", + name: "Chicken Penne Romano", + description: + "Grilled sliced chicken breast, baby spinach and mushrooms tossed with penne, sun-dried tomatoes and a homemade white wine cream sauce.", + price_cents: 1575, + unit: "per_person", + min_quantity: 15, + dietary_tags: [], + bundled_with: ["Mixed greens salad", "Garlic breadsticks", "Chef's dessert"], + }, + { + id: "ff-it-baked-ziti", + category: "Italian", + name: "Baked Ziti with Italian Sausage", + description: + "Classic ziti pasta with marinara, sweet Italian sausage, herbed ricotta and three cheese blend.", + price_cents: 1575, + unit: "per_person", + min_quantity: 15, + dietary_tags: [], + bundled_with: ["Mixed greens salad", "Garlic breadsticks", "Chef's dessert"], + }, + { + id: "ff-it-baked-manicotti", + category: "Italian", + name: "Baked Manicotti", + description: + "Large pasta tubes stuffed with bolognese sauce, ricotta and mozzarella cheese.", + price_cents: 1575, + unit: "per_person", + min_quantity: 15, + dietary_tags: [], + bundled_with: ["Mixed greens salad", "Garlic breadsticks", "Chef's dessert"], + }, + { + id: "ff-it-rosetta-parmesan", + category: "Italian", + name: "Chicken Rosetta Parmesan", + description: + "Hand breaded chicken breast with alfredo and marinara sauces, mozzarella and parmesan, served with pasta marinara.", + price_cents: 1950, + unit: "per_person", + min_quantity: 15, + dietary_tags: [], + bundled_with: ["Mixed greens salad", "Garlic breadsticks", "Chef's dessert"], + }, + { + id: "ff-it-marsala", + category: "Italian", + name: "Chicken Marsala", + description: + "Sautéed chicken breast with pancetta bacon, mushrooms and marsala. Served with red skin garlic mashed potatoes.", + price_cents: 1950, + unit: "per_person", + min_quantity: 15, + dietary_tags: [], + bundled_with: ["Mixed greens salad", "Garlic breadsticks", "Chef's dessert"], + }, + { + id: "ff-it-piccata", + category: "Italian", + name: "Chicken Piccata", + description: + "Pecorino and egg battered chicken breast with fresh thyme, lemon and caper butter, served with a side of pasta.", + price_cents: 1950, + unit: "per_person", + min_quantity: 15, + dietary_tags: [], + bundled_with: ["Mixed greens salad", "Garlic breadsticks", "Chef's dessert"], + }, + { + id: "ff-it-spaghetti-bolognese", + category: "Italian", + name: "Spaghetti Bolognese", + description: "Marinara sauce made with roasted pork and beef.", + price_cents: 1575, + unit: "per_person", + min_quantity: 15, + dietary_tags: [], + bundled_with: ["Mixed greens salad", "Garlic breadsticks", "Chef's dessert"], + }, + { + id: "ff-it-rigatoni-pesto-primavera", + category: "Italian", + name: "Rigatoni Pesto Primavera (V)", + description: + "Sweet bell peppers, cauliflower, zucchini and mushrooms in a creamy pesto alfredo sauce.", + price_cents: 1675, + unit: "per_person", + min_quantity: 15, + dietary_tags: ["vegetarian"], + bundled_with: ["Mixed greens salad", "Garlic breadsticks", "Chef's dessert"], + }, + { + id: "ff-it-grilled-pesto-chicken", + category: "Italian", + name: "Grilled Pesto Chicken (GF)", + description: + "Grilled chicken breast smothered in homemade pesto sauce, topped with tomato slices and mozzarella, served with pasta. (Chicken is GF.)", + price_cents: 1950, + unit: "per_person", + min_quantity: 15, + dietary_tags: ["gluten-free"], + bundled_with: ["Mixed greens salad", "Garlic breadsticks", "Chef's dessert"], + }, + + // ---- Hors d'Oeuvre Packages ---- + { + id: "ff-ho-italian-package", + category: "Hors d'Oeuvre Packages", + name: "Italian Hors d'Oeuvre Package", + description: + "Antipasto display, fruit skewers, goat cheese bruschetta, flatbread with sausage, spinach & artichoke dip, portabella empanada, sicilian arancini bolognese, italian dessert assortment.", + price_cents: 4450, + unit: "per_person", + min_quantity: 25, + dietary_tags: [], + }, + { + id: "ff-ho-southwest-package", + category: "Hors d'Oeuvre Packages", + name: "Southwest Hors d'Oeuvre Package", + description: + "Tortilla chips & salsa, fruit tray, vegetable & bean empanadas, spinach & artichoke crisps, beef taquitos, smoked turkey carving station, southwest dessert display.", + price_cents: 3150, + unit: "per_person", + min_quantity: 25, + dietary_tags: [], + }, + { + id: "ff-ho-traditional-package", + category: "Hors d'Oeuvre Packages", + name: "Traditional Hors d'Oeuvre Package", + description: + "Cheese tray, fruit tray, vegetable crudites, raspberry chipotle meatballs, mini hot dogs in puff pastry, mini beef wellington, teriyaki chicken skewers, brownies & bar cookies.", + price_cents: 3250, + unit: "per_person", + min_quantity: 25, + dietary_tags: [], + }, + { + id: "ff-ho-continental-package", + category: "Hors d'Oeuvre Packages", + name: "Continental Hors d'Oeuvre Package", + description: + "Beef tenderloin carving station, smoked salmon, imported cheese, fruit skewers, vegetable crudites, lobster & brie phyllo, portabella empanada, arancini, miniature dessert assortment.", + price_cents: 5550, + unit: "per_person", + min_quantity: 25, + dietary_tags: [], + }, + { + id: "ff-ho-passed-2", + category: "Hors d'Oeuvre Packages", + name: "Passed Appetizers — Choose 2", + description: "Pick any 2 passed appetizers (full-service events only).", + price_cents: 850, + unit: "per_person", + min_quantity: 15, + dietary_tags: [], + }, + { + id: "ff-ho-passed-3", + category: "Hors d'Oeuvre Packages", + name: "Passed Appetizers — Choose 3", + description: "Pick any 3 passed appetizers (full-service events only).", + price_cents: 1100, + unit: "per_person", + min_quantity: 15, + dietary_tags: [], + }, + + // ---- A la carte hors d'oeuvres (per-person, common $4.50) ---- + { id: "ff-ala-nashville-hot-chicken-waffle", category: "A la carte appetizers", name: "Nashville Hot Chicken & Waffle Skewer", price_cents: 450, unit: "per_person", min_quantity: 15, dietary_tags: [] }, + { id: "ff-ala-sesame-chicken-tenders", category: "A la carte appetizers", name: "Sesame Chicken Tenders", description: "with sweet thai chili sauce", price_cents: 450, unit: "per_person", min_quantity: 15, dietary_tags: [] }, + { id: "ff-ala-chicken-skewers", category: "A la carte appetizers", name: "Chicken Skewers", description: "Thai peanut, teriyaki, or BBQ", price_cents: 450, unit: "per_person", min_quantity: 15, dietary_tags: [] }, + { id: "ff-ala-chicken-taquitos", category: "A la carte appetizers", name: "Chicken Taquitos", description: "with southwest ranch dipping sauce", price_cents: 450, unit: "per_person", min_quantity: 15, dietary_tags: [] }, + { id: "ff-ala-buffalo-chicken-tortilla-crisp", category: "A la carte appetizers", name: "Buffalo Chicken Tortilla Crisp", price_cents: 450, unit: "per_person", min_quantity: 15, dietary_tags: [] }, + { id: "ff-ala-italian-sausage-stuffed-mushrooms", category: "A la carte appetizers", name: "Italian Sausage Stuffed Mushrooms", description: "with asiago cheese", price_cents: 450, unit: "per_person", min_quantity: 15, dietary_tags: [] }, + { id: "ff-ala-mini-cuban-sandwiches", category: "A la carte appetizers", name: "Mini Cuban Sandwiches", price_cents: 450, unit: "per_person", min_quantity: 15, dietary_tags: [] }, + { id: "ff-ala-pulled-pork-mango-flauta", category: "A la carte appetizers", name: "Pulled Pork & Mango Flauta", description: "with southwest ranch dipping sauce", price_cents: 450, unit: "per_person", min_quantity: 15, dietary_tags: [] }, + { id: "ff-ala-mini-crab-cake", category: "A la carte appetizers", name: "Mini Crab Cake", description: "with remoulade sauce", price_cents: 500, unit: "per_person", min_quantity: 15, dietary_tags: [] }, + { id: "ff-ala-crab-cheese-rangoons", category: "A la carte appetizers", name: "Crab & Cheese Rangoons", price_cents: 500, unit: "per_person", min_quantity: 15, dietary_tags: [] }, + { id: "ff-ala-iced-jumbo-shrimp", category: "A la carte appetizers", name: "Iced Jumbo Shrimp Cocktail", description: "13/15 count", price_cents: 500, unit: "each", dietary_tags: ["pescatarian"] }, + { id: "ff-ala-vegetable-spring-roll", category: "A la carte appetizers", name: "Vegetable Spring Roll", description: "with sweet thai chili sauce", price_cents: 450, unit: "per_person", min_quantity: 15, dietary_tags: ["vegetarian"] }, + { id: "ff-ala-spinach-pesto-puffs", category: "A la carte appetizers", name: "Spinach Pesto Puffs", price_cents: 450, unit: "per_person", min_quantity: 15, dietary_tags: ["vegetarian"] }, + { id: "ff-ala-deviled-eggs", category: "A la carte appetizers", name: "Gourmet Deviled Eggs", price_cents: 450, unit: "per_person", min_quantity: 15, dietary_tags: ["vegetarian"] }, + { id: "ff-ala-mac-cheese-bites", category: "A la carte appetizers", name: "Macaroni & Cheese Bites", description: "with chipotle ranch dipping sauce", price_cents: 450, unit: "per_person", min_quantity: 15, dietary_tags: ["vegetarian"] }, + { id: "ff-ala-antipasto-skewers", category: "A la carte appetizers", name: "Antipasto Skewers", price_cents: 450, unit: "per_person", min_quantity: 15, dietary_tags: [] }, + + // ---- Gourmet displays ---- + { id: "ff-disp-domestic-cheese", category: "Gourmet Displays", name: "Domestic Cheese Display", description: "with assorted crackers", price_cents: 450, unit: "per_person", min_quantity: 15, dietary_tags: ["vegetarian"] }, + { id: "ff-disp-imported-cheese", category: "Gourmet Displays", name: "Imported Cheese Display", price_cents: 550, unit: "per_person", min_quantity: 15, dietary_tags: ["vegetarian"] }, + { id: "ff-disp-fruit-cheese", category: "Gourmet Displays", name: "Fresh Fruit & Cheese Display", price_cents: 550, unit: "per_person", min_quantity: 15, dietary_tags: ["vegetarian"] }, + { id: "ff-disp-fruit", category: "Gourmet Displays", name: "Fresh Cut Fruit Display", description: "with poppy seed drizzle", price_cents: 450, unit: "per_person", min_quantity: 15, dietary_tags: ["vegan"] }, + { id: "ff-disp-veg-crudite", category: "Gourmet Displays", name: "Traditional Vegetable Crudite Display", description: "with pesto ranch dipping sauce", price_cents: 450, unit: "per_person", min_quantity: 15, dietary_tags: ["vegetarian"] }, + { id: "ff-disp-baked-brie", category: "Gourmet Displays", name: "Baked Brie with Apricot Preserves", price_cents: 625, unit: "per_person", min_quantity: 15, dietary_tags: ["vegetarian"] }, + { id: "ff-disp-italian-meat-cheese", category: "Gourmet Displays", name: "Italian Meat & Cheese Display", description: "Imported Italian meats and cheeses with focaccia, breadsticks, olives, peppers, artichokes and mushrooms.", price_cents: 1650, unit: "per_person", min_quantity: 15, dietary_tags: [] }, + { id: "ff-disp-smoked-salmon", category: "Gourmet Displays", name: "Whole Smoked Salmon", description: "Hickory smoked Pacific salmon on pickled onion, fennel and cucumber slaw with sliced baguette and crostini.", price_cents: 1550, unit: "per_person", min_quantity: 15, dietary_tags: ["pescatarian"] }, + { id: "ff-disp-grazing-board", category: "Gourmet Displays", name: "Grazing Board", description: "Assorted nuts, fruit spreads, berries, dried fruits, hard cheeses, salami, mortadella & pates with grissini sticks and crackers.", price_cents: 1950, unit: "per_person", min_quantity: 15, dietary_tags: [] }, + + // ---- Equipment / extras ---- + { + id: "ff-equip-chafing-dish", + category: "Equipment & extras", + name: "Chafing Dish Kit", + description: "Per-pan chafing setup for keeping food warm.", + price_cents: 1750, + unit: "each", + dietary_tags: [], + }, +]; + +// Export categories in display order for filter UIs. +export const FAT_FREDDYS_CATEGORIES = [ + "Breakfast", + "All American & BBQ", + "International", + "Italian", + "Hors d'Oeuvre Packages", + "A la carte appetizers", + "Gourmet Displays", + "Equipment & extras", +]; diff --git a/src/components/admin/hackathon-edit/catalog/formatCurrency.js b/src/components/admin/hackathon-edit/catalog/formatCurrency.js new file mode 100644 index 00000000..436c18a9 --- /dev/null +++ b/src/components/admin/hackathon-edit/catalog/formatCurrency.js @@ -0,0 +1,39 @@ +export const dollars = (cents) => (cents == null ? 0 : cents / 100); + +export const formatUSD = (cents) => { + if (cents == null || isNaN(cents)) return "—"; + return `$${(cents / 100).toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; +}; + +export const formatUSDCompact = (cents) => { + if (cents == null || isNaN(cents)) return "—"; + const d = cents / 100; + if (d >= 1000) return `$${d.toLocaleString("en-US", { maximumFractionDigits: 0 })}`; + return `$${d.toFixed(2)}`; +}; + +// Compute the per-item cost given a headcount. `each` items use a fixed qty +// stored on the item; `per_person` items use the meal's headcount. +export const computeItemCostCents = (item, headcount) => { + if (!item || !item.price_cents) return 0; + const unit = item.unit || "per_person"; + if (unit === "per_person") return Math.round(item.price_cents * Math.max(0, headcount || 0)); + if (unit === "each") return Math.round(item.price_cents * Math.max(0, item.quantity || 1)); + if (unit === "fixed") return item.price_cents; + return 0; +}; + +export const computeMealCostCents = (meal, headcount) => { + if (!meal || !Array.isArray(meal.items)) return 0; + const overriden = meal.headcount_override; + const effective = overriden != null && overriden !== "" ? Number(overriden) : headcount; + return meal.items.reduce((sum, it) => sum + computeItemCostCents(it, effective), 0); +}; + +export const computeAllMealsCostCents = (meals, headcount) => { + if (!Array.isArray(meals)) return 0; + return meals.reduce((sum, m) => sum + computeMealCostCents(m, headcount), 0); +}; diff --git a/src/components/admin/hackathon-edit/sections/CheckInSection.js b/src/components/admin/hackathon-edit/sections/CheckInSection.js new file mode 100644 index 00000000..4cda08e4 --- /dev/null +++ b/src/components/admin/hackathon-edit/sections/CheckInSection.js @@ -0,0 +1,35 @@ +import React from "react"; +import { useAuthInfo } from "@propelauth/react"; +import { Alert, Box } from "@mui/material"; +import { CheckInWorkbench } from "../../checkin/CheckInWorkbench"; +import SectionContainer from "../SectionContainer"; + +const CheckInSection = ({ admin, onSnack }) => { + const { userClass } = useAuthInfo(); + const eventId = admin.hackathon?.event_id; + + if (!eventId) { + return ( + + + Save the event ID under Overview before running check-in. + + + ); + } + + return ( + + + + + + ); +}; + +export default CheckInSection; diff --git a/src/components/admin/hackathon-edit/sections/DonationsSection.js b/src/components/admin/hackathon-edit/sections/DonationsSection.js new file mode 100644 index 00000000..1bd0688e --- /dev/null +++ b/src/components/admin/hackathon-edit/sections/DonationsSection.js @@ -0,0 +1,88 @@ +import React from "react"; +import { Grid, Stack, TextField, Typography } from "@mui/material"; +import SectionContainer from "../SectionContainer"; + +// Inline donations editor: every change autosaves through the parent admin +// hook. Replaces the standalone DonationManagement component (which had its +// own "Update Donation Data" button — no longer needed). +const DonationsSection = ({ admin }) => { + const { hackathon, setField } = admin; + const current = hackathon.donation_current || { food: "0", prize: "0", swag: "0", thank_you: "" }; + const goals = hackathon.donation_goals || { food: "0", prize: "0", swag: "0" }; + + const setCurrent = (key, value) => setField("donation_current", { ...current, [key]: value }); + const setGoal = (key, value) => setField("donation_goals", { ...goals, [key]: value }); + + return ( + + + + Raised so far + + setCurrent("food", e.target.value)} + /> + setCurrent("prize", e.target.value)} + /> + setCurrent("swag", e.target.value)} + /> + setCurrent("thank_you", e.target.value)} + helperText="Public message displayed alongside the funding totals." + /> + + + + Goals + + setGoal("food", e.target.value)} + /> + setGoal("prize", e.target.value)} + /> + setGoal("swag", e.target.value)} + /> + + + + + ); +}; + +export default DonationsSection; diff --git a/src/components/admin/hackathon-edit/sections/JudgesSection.js b/src/components/admin/hackathon-edit/sections/JudgesSection.js new file mode 100644 index 00000000..13d0aaaa --- /dev/null +++ b/src/components/admin/hackathon-edit/sections/JudgesSection.js @@ -0,0 +1,33 @@ +import React from "react"; +import { Stack, TextField, Typography } from "@mui/material"; +import SectionContainer from "../SectionContainer"; + +const JudgesSection = ({ admin }) => { + const { hackathon, setConstraint } = admin; + const constraints = hackathon.constraints || {}; + + return ( + + +
+ Venue arrival time + + What time judges should arrive at the venue (in the event timezone). Shown on the judge application's Availability step. Leave blank to use the default copy. + + setConstraint("judge_venue_arrival_time", e.target.value || null)} + InputLabelProps={{ shrink: true }} + sx={{ width: 200 }} + /> +
+
+
+ ); +}; + +export default JudgesSection; diff --git a/src/components/admin/hackathon-edit/sections/JudgingSection.js b/src/components/admin/hackathon-edit/sections/JudgingSection.js new file mode 100644 index 00000000..fa6b7140 --- /dev/null +++ b/src/components/admin/hackathon-edit/sections/JudgingSection.js @@ -0,0 +1,81 @@ +import React, { useState, useEffect, useMemo } from "react"; +import { Alert, Box, Tab, Tabs } from "@mui/material"; +import dynamic from "next/dynamic"; +import { useRouter } from "next/router"; +import SectionContainer from "../SectionContainer"; + +const JudgingRound1 = dynamic(() => import("../../JudgingRound1"), { ssr: false }); +const JudgingRound2 = dynamic(() => import("../../JudgingRound2"), { ssr: false }); +const JudgingResults = dynamic(() => import("../../JudgingResults"), { ssr: false }); + +const TAB_NAMES = ["round1", "round2", "results"]; + +const JudgingSection = ({ admin, orgId }) => { + const router = useRouter(); + const eventId = admin.hackathon?.event_id; + + const subtabFromUrl = typeof router.query.subtab === "string" ? router.query.subtab : null; + const initial = TAB_NAMES.indexOf(subtabFromUrl); + const [activeTab, setActiveTab] = useState(initial >= 0 ? initial : 0); + + useEffect(() => { + if (!subtabFromUrl) return; + const idx = TAB_NAMES.indexOf(subtabFromUrl); + if (idx >= 0 && idx !== activeTab) setActiveTab(idx); + }, [subtabFromUrl]); // eslint-disable-line react-hooks/exhaustive-deps + + // Children expect a hackathons[] array + a selected event_id. The event is + // already pinned by the URL here, so the single-item array is sufficient and + // setSelectedHackathon becomes a no-op. + const hackathons = useMemo( + () => (admin.hackathon ? [admin.hackathon] : []), + [admin.hackathon] + ); + const noopSetSelected = () => {}; + + const handleTabChange = (_e, newValue) => { + setActiveTab(newValue); + router.replace( + { pathname: router.pathname, query: { ...router.query, subtab: TAB_NAMES[newValue] } }, + undefined, + { shallow: true, scroll: false } + ); + }; + + if (!eventId) { + return ( + + + Save the event ID under Overview before managing judging. + + + ); + } + + const shared = { + orgId, + hackathons, + selectedHackathon: eventId, + setSelectedHackathon: noopSetSelected, + }; + + return ( + + + + + + + + + + + {activeTab === 0 && } + {activeTab === 1 && } + {activeTab === 2 && } + + + ); +}; + +export default JudgingSection; diff --git a/src/components/admin/hackathon-edit/sections/LinksSection.js b/src/components/admin/hackathon-edit/sections/LinksSection.js new file mode 100644 index 00000000..4377df2e --- /dev/null +++ b/src/components/admin/hackathon-edit/sections/LinksSection.js @@ -0,0 +1,20 @@ +import React from "react"; +import LinkManagement from "../../LinkManagement"; +import SectionContainer from "../SectionContainer"; + +const LinksSection = ({ admin }) => { + const { hackathon, setField } = admin; + return ( + + setField("links", newLinks)} + /> + + ); +}; + +export default LinksSection; diff --git a/src/components/admin/hackathon-edit/sections/MealsSection.js b/src/components/admin/hackathon-edit/sections/MealsSection.js new file mode 100644 index 00000000..800ca04a --- /dev/null +++ b/src/components/admin/hackathon-edit/sections/MealsSection.js @@ -0,0 +1,702 @@ +import React, { useMemo, useState } from "react"; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + Divider, + FormControl, + FormControlLabel, + Grid, + IconButton, + InputAdornment, + InputLabel, + MenuItem, + OutlinedInput, + Paper, + Select, + Stack, + Switch, + TextField, + ToggleButton, + ToggleButtonGroup, + Tooltip, + Typography, +} from "@mui/material"; +import { + Add as AddIcon, + ContentCopy as CloneIcon, + Delete as DeleteIcon, + DragIndicator as DragIcon, + MenuBook as CatalogIcon, + Restaurant as MealIcon, + Visibility as PreviewIcon, + VisibilityOff as PreviewOffIcon, + WbSunny as MorningIcon, + RestaurantMenu as LunchIcon, + NightsStay as DinnerIcon, + Coffee as SnackIcon, +} from "@mui/icons-material"; +import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; +import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker"; +import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns"; +import { format, parseISO } from "date-fns"; +import SectionContainer from "../SectionContainer"; +import MenuCatalogPicker from "../catalog/MenuCatalogPicker"; +import { + computeAllMealsCostCents, + computeItemCostCents, + computeMealCostCents, + formatUSD, +} from "../catalog/formatCurrency"; + +// Kept in sync with backend `ALLOWED_DIETARY_TAGS` in validators.py. +export const ALLOWED_DIETARY_TAGS = [ + "vegetarian", + "vegan", + "halal", + "kosher", + "gluten-free", + "dairy-free", + "nut-free", + "pescatarian", +]; + +// Default surcharge assumptions sourced from the Fat Freddy's quote +// (8.6% AZ tax, ~10% gratuity, $45 flat delivery, 2.9% credit-card +// surcharge). Used only for the "with fees" toggle. +const DEFAULT_SURCHARGES = { + tax_pct: 8.6, + gratuity_pct: 10, + delivery_cents: 4500, + card_surcharge_pct: 2.9, +}; + +const newId = () => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + +const blankItem = () => ({ id: newId(), name: "", description: "", dietary_tags: [] }); +const blankMeal = (overrides = {}) => ({ + id: newId(), + name: "", + time: "", + catering_provided: true, + dietary_tags: [], + items: [blankItem()], + ...overrides, +}); + +// Try to parse a meal time. Accepts ISO strings (preferred) and falls back to +// free-text. Returns null when unparseable so we can show the legacy free-text +// fallback for older data. +const parseMealTime = (value) => { + if (!value) return null; + if (value instanceof Date) return value; + try { + const d = parseISO(value); + if (!isNaN(d.getTime())) return d; + } catch {} + const d = new Date(value); + return isNaN(d.getTime()) ? null : d; +}; + +const formatMealTime = (value) => { + const d = parseMealTime(value); + if (d) return format(d, "EEE MMM d, h:mm a"); + return value || "Time not set"; +}; + +const QUICK_ADD = [ + { label: "Friday Dinner", icon: DinnerIcon }, + { label: "Saturday Breakfast", icon: MorningIcon }, + { label: "Saturday Lunch", icon: LunchIcon }, + { label: "Saturday Dinner", icon: DinnerIcon }, + { label: "Sunday Breakfast", icon: MorningIcon }, + { label: "Sunday Lunch", icon: LunchIcon }, + { label: "Snacks", icon: SnackIcon }, +]; + +const ItemCostRow = ({ item, headcount }) => { + if (!item.price_cents) return null; + const unit = item.unit || "per_person"; + const cost = computeItemCostCents(item, headcount); + return ( + + + {cost > 0 && ( + + ≈ {formatUSD(cost)} at {unit === "each" ? `qty ${item.quantity || 1}` : `${headcount} people`} + + )} + {item.vendor && ( + · {item.vendor} + )} + + ); +}; + +const MealEditor = ({ meal, mealIndex, onUpdate, onRemove, onClone, onOpenCatalog, dragHandleProps, eventStart, eventEnd, headcount }) => { + const updateField = (field, value) => onUpdate({ ...meal, [field]: value }); + const updateItem = (itemIndex, field, value) => { + const items = (meal.items || []).map((it, i) => (i === itemIndex ? { ...it, [field]: value } : it)); + onUpdate({ ...meal, items }); + }; + const addItem = () => onUpdate({ ...meal, items: [...(meal.items || []), blankItem()] }); + const removeItem = (itemIndex) => + onUpdate({ ...meal, items: (meal.items || []).filter((_, i) => i !== itemIndex) }); + + const parsedTime = parseMealTime(meal.time); + const overriddenHeadcount = meal.headcount_override != null && meal.headcount_override !== "" + ? Number(meal.headcount_override) + : headcount; + const mealCost = computeMealCostCents(meal, headcount); + + return ( + + + + + + + + + {meal.name || `Meal ${mealIndex + 1}`} + + {mealCost > 0 && ( + + )} + + + + + + + + + + + + + + + updateField("name", e.target.value)} + placeholder="Saturday Lunch" + /> + + + + updateField("time", d ? d.toISOString() : "")} + minDateTime={eventStart} + maxDateTime={eventEnd} + slotProps={{ textField: { size: "small", fullWidth: true } }} + /> + + + + updateField("headcount_override", e.target.value === "" ? null : Number(e.target.value))} + placeholder={String(headcount || 0)} + inputProps={{ min: 0 }} + helperText={meal.headcount_override == null ? `default ${headcount}` : "override"} + /> + + + + {meal.time && !parsedTime && ( + + Legacy free-text time — pick a real datetime above to upgrade. + + )} + + + updateField("catering_provided", e.target.checked)} + /> + } + label="Catering provided" + /> + + Slot dietary conformance + + + + + {meal.catering_provided !== false && ( + + + Menu options + + + + + + + {(meal.items || []).map((item, i) => ( + + + + updateItem(i, "name", e.target.value)} + /> + updateItem(i, "description", e.target.value)} + sx={{ mt: 1 }} + /> + + { + const dollars = parseFloat(e.target.value); + const cents = Number.isFinite(dollars) ? Math.round(dollars * 100) : null; + updateItem(i, "price_cents", cents); + }} + InputProps={{ startAdornment: $ }} + inputProps={{ min: 0, step: 0.25 }} + sx={{ width: 130 }} + /> + + Unit + + + {item.unit === "each" && ( + updateItem(i, "quantity", Math.max(0, parseInt(e.target.value, 10) || 0))} + inputProps={{ min: 0, step: 1 }} + sx={{ width: 90 }} + /> + )} + + Dietary tags + + + + + + removeItem(i)} aria-label="Remove item"> + + + + + ))} + {(meal.items || []).length === 0 && ( + + No items yet. Click "Browse menu" to pick from Fat Freddy's, or add a blank item. + + )} + + + )} + + + ); +}; + +const HackerPreview = ({ meals }) => { + if (!meals || meals.length === 0) { + return ( + + Hackers won't see a meal selector until you add at least one slot. + + ); + } + return ( + + {meals.map((meal) => ( + + + + + {meal.name || "(unnamed slot)"} + + + {formatMealTime(meal.time)} + + + + {(meal.dietary_tags || []).map((t) => ( + + ))} + + + {meal.catering_provided === false ? ( + + On your own (no catering provided) + + ) : ( + + {(meal.items || []).map((it) => ( + + {it.name || "(unnamed)"} + {it.description && ( + {it.description} + )} + {(it.dietary_tags || []).length > 0 && ( + + {(it.dietary_tags || []).map((t) => ( + + ))} + + )} + + ))} + + )} + + ))} + + ); +}; + +const CostSummary = ({ meals, headcount }) => { + const [showFees, setShowFees] = useState(true); + const subtotal = computeAllMealsCostCents(meals, headcount); + if (subtotal === 0) return null; + + const tax = Math.round((subtotal * DEFAULT_SURCHARGES.tax_pct) / 100); + const gratuity = Math.round((subtotal * DEFAULT_SURCHARGES.gratuity_pct) / 100); + const cardSurcharge = Math.round((subtotal * DEFAULT_SURCHARGES.card_surcharge_pct) / 100); + const delivery = DEFAULT_SURCHARGES.delivery_cents; + const grand = subtotal + tax + gratuity + cardSurcharge + delivery; + + return ( + + + + Estimated cost + + {formatUSD(showFees ? grand : subtotal)} + + + {meals.length} meal slot{meals.length === 1 ? "" : "s"} · {headcount} people · {showFees ? "with" : "without"} typical fees + + + setShowFees(e.target.checked)} />} + label="Include taxes & fees" + /> + + {showFees && ( + + + + + + + + + + + + Surcharges based on Fat Freddy's quote (Phoenix, AZ). Adjust with the vendor. + + + )} + + ); +}; + +const CostLine = ({ label, value, bold }) => ( + + {label} + {formatUSD(value)} + +); + +const MealsSection = ({ admin }) => { + const { hackathon, setConstraint, markSectionDirty, dirtySections, commitSection, discardSection, saveState } = admin; + const meals = hackathon.constraints?.meals || []; + const headcount = hackathon.constraints?.meals_estimated_headcount ?? 50; + const dirty = dirtySections.has("meals"); + const saving = saveState.status === "saving"; + const [showPreview, setShowPreview] = useState(true); + const [catalogOpen, setCatalogOpen] = useState(false); + const [catalogTargetMeal, setCatalogTargetMeal] = useState(null); + + const eventStart = useMemo( + () => (hackathon.start_date ? new Date(`${hackathon.start_date}T00:00:00`) : null), + [hackathon.start_date] + ); + const eventEnd = useMemo( + () => (hackathon.end_date ? new Date(`${hackathon.end_date}T23:59:59`) : null), + [hackathon.end_date] + ); + + const updateMeals = (next) => { + setConstraint("meals", next); + markSectionDirty("meals", true); + }; + + const updateOne = (index, value) => + updateMeals(meals.map((m, i) => (i === index ? value : m))); + const removeOne = (index) => updateMeals(meals.filter((_, i) => i !== index)); + const addNew = (presetName) => + updateMeals([...meals, blankMeal(presetName ? { name: presetName } : {})]); + const cloneOne = (index) => { + const src = meals[index]; + const dup = { + ...src, + id: newId(), + name: src.name ? `${src.name} (copy)` : "", + items: (src.items || []).map((it) => ({ ...it, id: newId() })), + }; + const next = [...meals]; + next.splice(index + 1, 0, dup); + updateMeals(next); + }; + + const setHeadcount = (value) => { + setConstraint("meals_estimated_headcount", value); + // headcount is purely an estimation aid — no need to mark dirty + }; + + const onDragEnd = (result) => { + if (!result.destination) return; + if (result.destination.index === result.source.index) return; + const next = Array.from(meals); + const [moved] = next.splice(result.source.index, 1); + next.splice(result.destination.index, 0, moved); + updateMeals(next); + }; + + const openCatalogFor = (mealIndex) => { + setCatalogTargetMeal(mealIndex); + setCatalogOpen(true); + }; + + const handleCatalogAdd = (newItems) => { + if (catalogTargetMeal == null) return; + const target = meals[catalogTargetMeal]; + if (!target) return; + const updated = { ...target, items: [...(target.items || []), ...newItems] }; + updateOne(catalogTargetMeal, updated); + setCatalogTargetMeal(null); + }; + + return ( + v && setShowPreview(v === "preview")} + > + Edit only + With preview + + } + dirty={dirty} + saving={saving} + onSave={() => commitSection("meals")} + onDiscard={() => discardSection("meals")} + > + + + setHeadcount(Math.max(0, parseInt(e.target.value, 10) || 0))} + inputProps={{ min: 0, step: 1 }} + helperText="Used for cost estimates and per-meal defaults" + sx={{ maxWidth: 220 }} + /> + + + + + + + + {meals.length === 0 ? ( + + + No meals configured yet. Pick a quick-add preset below or build from scratch. + + + ) : ( + + + {(provided) => ( + + {meals.map((meal, index) => ( + + {(p) => ( + + updateOne(index, v)} + onRemove={() => removeOne(index)} + onClone={() => cloneOne(index)} + onOpenCatalog={() => openCatalogFor(index)} + dragHandleProps={p.dragHandleProps} + eventStart={eventStart} + eventEnd={eventEnd} + headcount={headcount} + /> + + )} + + ))} + {provided.placeholder} + + )} + + + )} + + + + + + {QUICK_ADD.map(({ label, icon: Icon }) => ( + + ))} + + + + + {showPreview && ( + + + Hacker preview + + Roughly how the meal selector renders on the hacker application. + + + + + )} + + + { + setCatalogOpen(false); + setCatalogTargetMeal(null); + }} + onAddItems={handleCatalogAdd} + headcount={ + catalogTargetMeal != null && meals[catalogTargetMeal]?.headcount_override != null && meals[catalogTargetMeal].headcount_override !== "" + ? Number(meals[catalogTargetMeal].headcount_override) + : headcount + } + /> + + ); +}; + +export default MealsSection; diff --git a/src/components/admin/hackathon-edit/sections/MediaSection.js b/src/components/admin/hackathon-edit/sections/MediaSection.js new file mode 100644 index 00000000..d481c238 --- /dev/null +++ b/src/components/admin/hackathon-edit/sections/MediaSection.js @@ -0,0 +1,30 @@ +import React from "react"; +import dynamic from "next/dynamic"; +import SectionContainer from "../SectionContainer"; + +const EventMediaManagement = dynamic( + () => import("../../EventMediaManagement"), + { ssr: false }, +); + +const MediaSection = ({ admin, accessToken, orgId }) => { + const { hackathon, setField } = admin; + return ( + + setField("event_photos", p)} + socialPosts={hackathon.social_posts || []} + onSocialPostsChange={(s) => setField("social_posts", s)} + /> + + ); +}; + +export default MediaSection; diff --git a/src/components/admin/hackathon-edit/sections/NonprofitsSection.js b/src/components/admin/hackathon-edit/sections/NonprofitsSection.js new file mode 100644 index 00000000..d57d809e --- /dev/null +++ b/src/components/admin/hackathon-edit/sections/NonprofitsSection.js @@ -0,0 +1,28 @@ +import React from "react"; +import dynamic from "next/dynamic"; +import SectionContainer from "../SectionContainer"; + +const NonprofitManagement = dynamic(() => import("../../NonprofitManagement"), { ssr: false }); + +const NonprofitsSection = ({ admin, accessToken, orgId, onSnack }) => { + const { hackathon, refetch } = admin; + return ( + + { + onSnack?.("Nonprofit settings updated", "success"); + refetch(); + }} + onError={(msg) => onSnack?.(msg || "Failed to update nonprofits", "error")} + /> + + ); +}; + +export default NonprofitsSection; diff --git a/src/components/admin/hackathon-edit/sections/OverviewSection.js b/src/components/admin/hackathon-edit/sections/OverviewSection.js new file mode 100644 index 00000000..7fee350f --- /dev/null +++ b/src/components/admin/hackathon-edit/sections/OverviewSection.js @@ -0,0 +1,174 @@ +import React from "react"; +import { Box, Stack, TextField, Typography, Grid, InputAdornment, Tooltip, IconButton } from "@mui/material"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; +import GitHubIcon from "@mui/icons-material/GitHub"; +import dynamic from "next/dynamic"; +import SectionContainer from "../SectionContainer"; +import { DEFAULT_EVENT_TIMEZONE } from "../../../../lib/timezoneUtils"; + +const TimezoneSelect = dynamic(() => import("react-timezone-select"), { ssr: false }); + +// Normalize whatever the admin typed (full URL, @handle, trailing slash) into a +// bare GitHub org slug so we can build a clean github.com/ link. +const githubOrgSlug = (raw) => { + if (!raw) return ""; + let s = String(raw).trim(); + s = s.replace(/^https?:\/\/(www\.)?github\.com\//i, ""); + s = s.replace(/^@/, "").replace(/\/.*$/, "").trim(); + return s; +}; + +const OverviewSection = ({ admin }) => { + const { hackathon, setField, markSectionDirty, dirtySections, commitSection, discardSection, saveState } = admin; + const dirty = dirtySections.has("overview-dates"); + const saving = saveState.status === "saving"; + + const setDateField = (field, value) => { + setField(field, value); + markSectionDirty("overview-dates", true); + }; + + return ( + commitSection("overview-dates")} + onDiscard={() => discardSection("overview-dates")} + > + + setField("title", e.target.value)} + /> + setField("description", e.target.value)} + helperText="Markdown is supported on the public page." + /> + + + setDateField("start_date", e.target.value)} + InputLabelProps={{ shrink: true }} + /> + + + setDateField("end_date", e.target.value)} + InputLabelProps={{ shrink: true }} + /> + + + + Event timezone + setDateField("timezone", tz.value)} + /> + + Used as the default timezone for countdowns. Save when you change this. + + + + + setField("location", e.target.value)} + placeholder="ASU Tempe, Arizona" + /> + + + setField("type", e.target.value)} + placeholder="In-person / Virtual / Hybrid" + /> + + + setField("image_url", e.target.value)} + helperText="Shown at the top of the public event page." + /> + + setField("github_org", e.target.value)} + placeholder="opportunity-hack" + helperText={ + githubOrgSlug(hackathon.github_org) + ? `Links to github.com/${githubOrgSlug(hackathon.github_org)}` + : "Org slug used to link teams to GitHub (github.com/) and scope repo lookups. Just the org name, not the full URL." + } + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: githubOrgSlug(hackathon.github_org) ? ( + + + + + + + + ) : null, + }} + /> + setField("mentor_slack_channel", e.target.value)} + placeholder={`${hackathon.event_id || "your-event"}-mentors`.replace(/_/g, "-").toLowerCase()} + helperText="Heartbeats for raised mentor flags get posted here. Defaults to -mentors if empty." + /> + + + ); +}; + +export default OverviewSection; diff --git a/src/components/admin/hackathon-edit/sections/ParticipantsSection.js b/src/components/admin/hackathon-edit/sections/ParticipantsSection.js new file mode 100644 index 00000000..6736d8c2 --- /dev/null +++ b/src/components/admin/hackathon-edit/sections/ParticipantsSection.js @@ -0,0 +1,367 @@ +import React from "react"; +import { + Alert, + Box, + Button, + Card, + CardContent, + FormControl, + FormControlLabel, + Grid, + IconButton, + InputLabel, + MenuItem, + Select, + Stack, + Switch, + TextField, + Typography, +} from "@mui/material"; +import { + Add as AddIcon, + Delete as DeleteIcon, + Person as HackerIcon, + School as MentorIcon, + Gavel as JudgeIcon, + Apartment as SponsorIcon, + VolunteerActivism as NonprofitIcon, + QuizOutlined as QuizIcon, +} from "@mui/icons-material"; +import SectionContainer from "../SectionContainer"; + +const urlIsValid = (v) => !v || /^https?:\/\//.test(v); + +const ToggleRow = ({ icon: Icon, title, subtitle, checked, onChange, children }) => ( + + + + + + + + {title} + {subtitle && ( + {subtitle} + )} + + onChange(e.target.checked)} /> + + {checked && children && {children}} + + + + +); + +const ExternalUrlField = ({ label, value, onChange }) => ( + onChange(e.target.value)} + fullWidth + size="small" + helperText="Leave blank to use the built-in form. Must start with http:// or https://." + error={!urlIsValid(value)} + /> +); + +const ParticipantsSection = ({ admin }) => { + const { hackathon, setConstraint, setDeposit, markSectionDirty, dirtySections, commitSection, discardSection, saveState } = admin; + const constraints = hackathon.constraints || {}; + const deposit = constraints.hacker_deposit || { enabled: false, default_amount_cents: 500 }; + const questions = constraints.hacker_required_questions?.questions || []; + + const setQuestionsExplicit = (next) => { + setConstraint("hacker_required_questions", { questions: next }); + markSectionDirty("screening", true); + }; + const setDepositExplicit = (field, value) => { + setDeposit(field, value); + markSectionDirty("deposit", true); + }; + + const addQuestion = () => + setQuestionsExplicit([...questions, { question: "", required_answer: true, error: "" }]); + const updateQuestion = (i, field, value) => { + const next = questions.map((q, idx) => (idx === i ? { ...q, [field]: value } : q)); + setQuestionsExplicit(next); + }; + const removeQuestion = (i) => setQuestionsExplicit(questions.filter((_, idx) => idx !== i)); + + const screeningDirty = dirtySections.has("screening"); + const depositDirty = dirtySections.has("deposit"); + const saving = saveState.status === "saving"; + + return ( + + + + setConstraint("application_hacker_enabled", v)} + > + + setConstraint("application_hacker_external_url", v)} + /> + setConstraint("application_hacker_force_open", e.target.checked)} + /> + } + label={ + + + Force applications open (override deadline) + + + Bypasses the automatic "applications closed" cutoff (3 days before start) + and the "event ended" wall. Turn off after accepting late entries. + + + } + /> + + + + setConstraint("application_mentor_enabled", v)} + > + setConstraint("application_mentor_external_url", v)} + /> + + + setConstraint("application_judge_enabled", v)} + > + + setConstraint("application_judge_external_url", v)} + /> + setConstraint("application_judge_enabled_code", e.target.value)} + helperText="Optional code that lets approved judges apply even when this toggle is off." + /> + + + + setConstraint("application_nonprofit_enabled", v)} + /> + + setConstraint("application_sponsor_enabled", v)} + /> + + + + + + + + setConstraint("min_people_per_team", parseInt(e.target.value, 10))} + /> + + + setConstraint("max_people_per_team", parseInt(e.target.value, 10))} + /> + + + setConstraint("max_teams_per_problem", parseInt(e.target.value, 10))} + /> + + + + setConstraint("team_creation_enabled", e.target.checked)} + /> + } + label="Allow hackers to create teams" + /> + setConstraint("team_join_enabled", e.target.checked)} + /> + } + label="Allow hackers to join teams" + /> + setConstraint("team_find_a_team_enabled", e.target.checked)} + /> + } + label='Show the "Find a team" matchmaking page' + /> + + + + + commitSection("deposit")} + onDiscard={() => discardSection("deposit")} + > + + setDepositExplicit("enabled", e.target.checked)} + /> + } + label={ + + Require hacker deposit + + Adds a Stripe Checkout step to the hacker application. + + + } + /> + {deposit.enabled && ( + { + const dollars = parseFloat(e.target.value); + const cents = Number.isFinite(dollars) ? Math.round(dollars * 100) : 500; + setDepositExplicit("default_amount_cents", cents); + }} + inputProps={{ min: 1, max: 500, step: 1 }} + helperText="Defaults to $5. Hackers can choose to pay more on the form." + sx={{ maxWidth: 240 }} + /> + )} + + + + commitSection("screening")} + onDiscard={() => discardSection("screening")} + > + + {questions.length === 0 && ( + }> + No screening questions. Hackers proceed straight to the application. + + )} + {questions.map((q, i) => ( + + + + Question {i + 1} + removeQuestion(i)} aria-label="Remove question"> + + + + + updateQuestion(i, "question", e.target.value)} + placeholder="e.g., Are you a current member of ASU WiCS?" + /> + + Required answer + + + updateQuestion(i, "error", e.target.value)} + placeholder="e.g., This hackathon is only open to ASU WiCS members." + /> + + + + ))} + + + + + + + ); +}; + +export default ParticipantsSection; diff --git a/src/components/admin/hackathon-edit/sections/PlanningSection.js b/src/components/admin/hackathon-edit/sections/PlanningSection.js new file mode 100644 index 00000000..75c44f82 --- /dev/null +++ b/src/components/admin/hackathon-edit/sections/PlanningSection.js @@ -0,0 +1,99 @@ +import React from "react"; +import { Alert, Box, Button, Divider, FormControlLabel, Stack, Switch } from "@mui/material"; +import { Launch as LaunchIcon } from "@mui/icons-material"; +import dynamic from "next/dynamic"; +import SectionContainer from "../SectionContainer"; + +const PlanningSlackSettings = dynamic(() => import("../../../Planning/PlanningSlackSettings"), { ssr: false }); +const PlanningEditorsManager = dynamic(() => import("../../../Planning/PlanningEditorsManager"), { ssr: false }); + +const PlanningSection = ({ admin, accessToken, orgId }) => { + const { hackathon, setField, setPlanning } = admin; + const planning = hackathon.planning || {}; + const eventId = hackathon.event_id; + + const handleSeed = async () => { + await fetch(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/planning/${eventId}/seed-template`, { + method: "POST", + headers: { authorization: `Bearer ${accessToken}`, "X-Org-Id": orgId }, + }); + setPlanning({ template_seeded: true }); + }; + + return ( + + + setPlanning({ enabled: e.target.checked })} + /> + } + label="Enable planning board (makes /hack/[event_id]/plan public)" + /> + setPlanning({ budget_widget_on_event_page: e.target.checked })} + /> + } + label="Show budget widget on the public event page" + /> + {eventId && ( + + + + )} + {planning.enabled && !planning.template_seeded && ( + + Apply template + + } + > + Template not yet applied. Seed the OHack default lists and cards. + + )} + + setPlanning(config)} + onSlackNotify={async () => { + await fetch(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/planning/${eventId}/slack/notify`, { + method: "POST", + headers: { authorization: `Bearer ${accessToken}`, "X-Org-Id": orgId }, + }); + }} + /> + + { + const res = await fetch(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/planning/${eventId}/editors`, { + method: "PATCH", + headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json", "X-Org-Id": orgId }, + body: JSON.stringify({ add, remove }), + }); + if (res.ok) { + const data = await res.json(); + setField("planning", { ...(hackathon.planning || {}), editors: data.editors }); + return { ok: true }; + } + return { ok: false, error: "Failed" }; + }} + /> + + + ); +}; + +export default PlanningSection; diff --git a/src/components/admin/hackathon-edit/sections/ScheduleSection.js b/src/components/admin/hackathon-edit/sections/ScheduleSection.js new file mode 100644 index 00000000..40bd9a06 --- /dev/null +++ b/src/components/admin/hackathon-edit/sections/ScheduleSection.js @@ -0,0 +1,404 @@ +import React, { useMemo, useState } from "react"; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + Divider, + IconButton, + Paper, + Stack, + Tab, + Tabs, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import { + Add as AddIcon, + Delete as DeleteIcon, + DragIndicator as DragIcon, + ExpandLess as CollapseIcon, + ExpandMore as ExpandIcon, + RocketLaunch as KickoffIcon, + School as WorkshopIcon, + Restaurant as LunchIcon, + Gavel as JudgingIcon, + EmojiEvents as AwardsIcon, + Coffee as CoffeeIcon, + Stop as EndIcon, + ViewAgenda as TimelineViewIcon, + ViewList as ListViewIcon, +} from "@mui/icons-material"; +import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; +import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker"; +import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns"; +import dynamic from "next/dynamic"; +import { format, parseISO } from "date-fns"; +import SectionContainer from "../SectionContainer"; +import { DEFAULT_EVENT_TIMEZONE } from "../../../../lib/timezoneUtils"; + +const TimezoneSelect = dynamic(() => import("react-timezone-select"), { ssr: false }); +const ReactMarkdown = dynamic(() => import("react-markdown"), { ssr: false }); + +const newId = () => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + +// ISO formatter that bakes a chosen timezone offset into the saved string, +// matching the existing CountdownManagement save format expected by the +// backend and the public countdown component. +const toIsoWithTimezone = (date, timezone) => { + if (!date) return ""; + const d = date instanceof Date ? date : new Date(date); + if (isNaN(d.getTime())) return ""; + const pad = (n) => n.toString().padStart(2, "0"); + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + timeZoneName: "short", + }); + const parts = formatter.formatToParts(d).reduce((acc, p) => { + acc[p.type] = p.value; + return acc; + }, {}); + const tzPart = formatter.formatToParts(d).find((p) => p.type === "timeZoneName"); + let offset = "+0000"; + if (tzPart) { + const m = tzPart.value.match(/([+-])(\d{2}):?(\d{2})/); + if (m) offset = `${m[1]}${m[2]}${m[3]}`; + else { + const tz = d.getTimezoneOffset(); + const sign = tz <= 0 ? "+" : "-"; + offset = `${sign}${pad(Math.abs(Math.floor(tz / 60)))}${pad(Math.abs(tz % 60))}`; + } + } + return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}${offset}`; +}; + +const safeParse = (value) => { + if (!value) return null; + try { + const d = parseISO(value); + if (!isNaN(d.getTime())) return d; + } catch {} + const d = new Date(value); + return isNaN(d.getTime()) ? null : d; +}; + +const formatDayHeader = (date, timezone) => + date.toLocaleDateString("en-US", { timeZone: timezone, weekday: "long", month: "short", day: "numeric" }); + +const formatTimeOfDay = (date, timezone) => + date.toLocaleTimeString("en-US", { timeZone: timezone, hour: "numeric", minute: "2-digit" }); + +const PRESETS = [ + { key: "kickoff", label: "Kickoff", icon: KickoffIcon, addHours: 0 }, + { key: "workshop", label: "Workshop", icon: WorkshopIcon, addHours: 1 }, + { key: "coffee", label: "Coffee break", icon: CoffeeIcon, addHours: 3 }, + { key: "lunch", label: "Lunch", icon: LunchIcon, addHours: 5 }, + { key: "judging", label: "Judging starts", icon: JudgingIcon, fromEnd: -6 }, + { key: "awards", label: "Awards", icon: AwardsIcon, fromEnd: -1 }, + { key: "end", label: "Wrap-up", icon: EndIcon, fromEnd: 0 }, +]; + +const buildPresetTime = (preset, eventStart, eventEnd) => { + const base = preset.fromEnd != null ? eventEnd : eventStart; + if (!base) return new Date(); + const d = new Date(base); + if (preset.fromEnd != null) d.setHours(d.getHours() + preset.fromEnd); + else d.setHours(d.getHours() + (preset.addHours || 0)); + return d; +}; + +const CountdownCard = ({ countdown, index, timezone, dragHandleProps, onUpdate, onDelete, eventStart, eventEnd }) => { + const [expanded, setExpanded] = useState(false); + const time = safeParse(countdown.time); + + const update = (field, value) => onUpdate({ ...countdown, [field]: value }); + + return ( + + + + + + + + update("name", e.target.value)} + variant="standard" + placeholder={`Countdown ${index + 1}`} + fullWidth + InputProps={{ disableUnderline: !countdown.name, sx: { fontWeight: 600, fontSize: "1rem" } }} + /> + + {time ? `${formatDayHeader(time, timezone)} · ${formatTimeOfDay(time, timezone)}` : "No time set"} + + + + setExpanded((v) => !v)}> + {expanded ? : } + + + + + + + + + + {expanded && ( + + + + update("time", d ? toIsoWithTimezone(d, timezone) : "")} + minDateTime={eventStart} + maxDateTime={eventEnd} + slotProps={{ textField: { size: "small", fullWidth: true } }} + /> + + update("description", e.target.value)} + helperText="Markdown supported." + /> + {countdown.description && ( + + Preview + + {countdown.description} + + + )} + + + )} + + + ); +}; + +const ScheduleSection = ({ admin }) => { + const { hackathon, setField, markSectionDirty, dirtySections, commitSection, discardSection, saveState } = admin; + const countdowns = hackathon.countdowns || []; + const dirty = dirtySections.has("schedule"); + const saving = saveState.status === "saving"; + const eventTimezone = hackathon.timezone || DEFAULT_EVENT_TIMEZONE; + const [view, setView] = useState("timeline"); + const [timezone, setTimezone] = useState(eventTimezone); + + const eventStart = useMemo( + () => (hackathon.start_date ? new Date(`${hackathon.start_date}T00:00:00`) : null), + [hackathon.start_date] + ); + const eventEnd = useMemo( + () => (hackathon.end_date ? new Date(`${hackathon.end_date}T23:59:59`) : null), + [hackathon.end_date] + ); + + const updateAll = (next) => { + setField("countdowns", next); + markSectionDirty("schedule", true); + }; + + const updateOne = (index, value) => updateAll(countdowns.map((c, i) => (i === index ? value : c))); + const removeOne = (index) => updateAll(countdowns.filter((_, i) => i !== index)); + + const addPreset = (preset) => { + const time = buildPresetTime(preset, eventStart, eventEnd); + updateAll([ + ...countdowns, + { + id: newId(), + name: preset.label, + description: "", + time: toIsoWithTimezone(time, timezone), + }, + ]); + }; + + const addBlank = () => { + const time = eventStart || new Date(); + updateAll([ + ...countdowns, + { id: newId(), name: "", description: "", time: toIsoWithTimezone(time, timezone) }, + ]); + }; + + // Group by day for timeline view (preserves user-defined order within day + // and sorts groups by date). + const grouped = useMemo(() => { + const groups = new Map(); + countdowns.forEach((c, idx) => { + const d = safeParse(c.time); + const key = d ? format(d, "yyyy-MM-dd") : "no-date"; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push({ ...c, _origIndex: idx }); + }); + return Array.from(groups.entries()) + .map(([key, items]) => ({ + key, + date: key === "no-date" ? null : new Date(`${key}T00:00:00`), + items: items.sort((a, b) => { + const da = safeParse(a.time); + const db = safeParse(b.time); + if (!da) return 1; + if (!db) return -1; + return da - db; + }), + })) + .sort((a, b) => { + if (!a.date) return 1; + if (!b.date) return -1; + return a.date - b.date; + }); + }, [countdowns]); + + const onListDragEnd = (result) => { + if (!result.destination) return; + if (result.destination.index === result.source.index) return; + const next = Array.from(countdowns); + const [moved] = next.splice(result.source.index, 1); + next.splice(result.destination.index, 0, moved); + updateAll(next); + }; + + return ( + setView(v)} sx={{ minHeight: 0 }}> + } iconPosition="start" label="Timeline" sx={{ minHeight: 0, py: 0.5 }} /> + } iconPosition="start" label="List" sx={{ minHeight: 0, py: 0.5 }} /> + + } + dirty={dirty} + saving={saving} + onSave={() => commitSection("schedule")} + onDiscard={() => discardSection("schedule")} + > + + Display timezone + + setTimezone(tz.value)} + /> + + + Times you set are saved with this timezone offset. Defaults to the event's timezone ({eventTimezone}). + + + + + + + + {PRESETS.map((preset) => ( + + ))} + + + + {countdowns.length === 0 && ( + + No countdowns yet. Quick-add a Kickoff to get started, or use Custom for a one-off. + + )} + + {view === "timeline" && countdowns.length > 0 && ( + + {grouped.map((group) => ( + + + + + {group.date ? formatDayHeader(group.date, timezone) : "No date set"} + + + + + {group.items.map((item) => ( + updateOne(item._origIndex, v)} + onDelete={() => removeOne(item._origIndex)} + dragHandleProps={{}} + eventStart={eventStart} + eventEnd={eventEnd} + /> + ))} + + + ))} + + Switch to List view to drag-reorder. + + + )} + + {view === "list" && countdowns.length > 0 && ( + + + {(provided) => ( + + {countdowns.map((c, idx) => ( + + {(p) => ( + + updateOne(idx, v)} + onDelete={() => removeOne(idx)} + dragHandleProps={p.dragHandleProps} + eventStart={eventStart} + eventEnd={eventEnd} + /> + + )} + + ))} + {provided.placeholder} + + )} + + + )} + + ); +}; + +export default ScheduleSection; diff --git a/src/components/admin/hackathon-edit/sections/TeamsSection.js b/src/components/admin/hackathon-edit/sections/TeamsSection.js new file mode 100644 index 00000000..c4514cb5 --- /dev/null +++ b/src/components/admin/hackathon-edit/sections/TeamsSection.js @@ -0,0 +1,85 @@ +import React, { useState, useEffect } from "react"; +import { Alert, Box, Tab, Tabs } from "@mui/material"; +import dynamic from "next/dynamic"; +import { useRouter } from "next/router"; +import SectionContainer from "../SectionContainer"; + +// Heavy admin sub-pages — lazy-loaded so the section bundle stays small. +const TeamManagement = dynamic( + () => import("../../TeamManagement"), + { ssr: false } +); +const TeamAssignments = dynamic( + () => import("../../TeamAssignments"), + { ssr: false } +); + +const TAB_NAMES = ["management", "assignments", "stats"]; + +const TeamsSection = ({ admin, orgId }) => { + const router = useRouter(); + // TeamManagement / TeamAssignments key everything off the Firestore doc id + // (their fetchTeams hits `/api/team/${docId}`). The URL path's [event_id] is + // the slug, so we pass `admin.hackathon.id` (the doc id) — passing the slug + // here breaks the team fetch with a 404. + const hackathonDocId = admin.hackathon?.id; + const eventId = admin.hackathon?.event_id; + + // Sub-tab persisted via ?subtab= so the URL stays shareable. + const subtabFromUrl = typeof router.query.subtab === "string" ? router.query.subtab : null; + const initialTab = Math.max(0, TAB_NAMES.indexOf(subtabFromUrl)); + const [activeTab, setActiveTab] = useState(initialTab >= 0 ? initialTab : 0); + + useEffect(() => { + if (!subtabFromUrl) return; + const idx = TAB_NAMES.indexOf(subtabFromUrl); + if (idx >= 0 && idx !== activeTab) setActiveTab(idx); + }, [subtabFromUrl]); // eslint-disable-line react-hooks/exhaustive-deps + + const handleTabChange = (_e, newValue) => { + setActiveTab(newValue); + router.replace( + { + pathname: router.pathname, + query: { ...router.query, subtab: TAB_NAMES[newValue] }, + }, + undefined, + { shallow: true, scroll: false } + ); + }; + + if (!eventId || !hackathonDocId) { + return ( + + + Save the event under Overview before managing teams for this hackathon. + + + ); + } + + return ( + + + + + + + + + + + {activeTab === 0 && } + {activeTab === 1 && } + {activeTab === 2 && ( + + Team statistics dashboard is coming soon. This will include insights on team + performance, engagement metrics, and project progress tracking. + + )} + + + ); +}; + +export default TeamsSection; diff --git a/src/components/admin/hackathon-edit/sections/VolunteerSection.js b/src/components/admin/hackathon-edit/sections/VolunteerSection.js new file mode 100644 index 00000000..50f39231 --- /dev/null +++ b/src/components/admin/hackathon-edit/sections/VolunteerSection.js @@ -0,0 +1,35 @@ +import React from "react"; +import { useAuthInfo } from "@propelauth/react"; +import { Box, Alert } from "@mui/material"; +import { VolunteerWorkbench } from "../../volunteer/VolunteerWorkbench"; +import SectionContainer from "../SectionContainer"; + +const VolunteerSection = ({ admin, onSnack }) => { + const { userClass } = useAuthInfo(); + const eventId = admin.hackathon?.event_id; + + if (!eventId) { + return ( + + + Save the event ID under Overview before managing applications. + + + ); + } + + return ( + + + + + + ); +}; + +export default VolunteerSection; diff --git a/src/components/admin/hackathon-edit/sectionsManifest.js b/src/components/admin/hackathon-edit/sectionsManifest.js new file mode 100644 index 00000000..39465d42 --- /dev/null +++ b/src/components/admin/hackathon-edit/sectionsManifest.js @@ -0,0 +1,46 @@ +// Single source of truth for the per-event admin sidebar. +// +// Adding a new section: append here, then implement the matching component in +// ./sections/Section.js. The page picks the component by `slug`. + +import { + Article as OverviewIcon, + Schedule as ScheduleIcon, + Restaurant as MealsIcon, + PeopleAlt as ParticipantsIcon, + Gavel as JudgesIcon, + VolunteerActivism as NonprofitsIcon, + PhotoLibrary as MediaIcon, + Dashboard as PlanningIcon, + AttachMoney as DonationsIcon, + Link as LinksIcon, + Badge as VolunteerAdminIcon, + GroupWork as TeamsIcon, + Balance as JudgingIcon, + QrCodeScanner as CheckInIcon, +} from "@mui/icons-material"; + +// Sections are split into two groups visually (config vs. operations) but +// share a flat slug namespace. The "ops" sections (volunteer/teams/judging/ +// check-in) replace the old standalone /admin/{slug} pages — those routes now +// redirect into here. +export const SECTIONS = [ + { slug: "overview", label: "Overview", icon: OverviewIcon, hint: "Title, dates, location, image", group: "config" }, + { slug: "schedule", label: "Schedule", icon: ScheduleIcon, hint: "Countdowns and event timeline", group: "config" }, + { slug: "meals", label: "Meals", icon: MealsIcon, hint: "Meal slots and menus for hackers", group: "config" }, + { slug: "participants", label: "Participants", icon: ParticipantsIcon, hint: "Applications, teams, screening", group: "config" }, + { slug: "judges", label: "Judges", icon: JudgesIcon, hint: "Judging settings and arrival time", group: "config" }, + { slug: "nonprofits", label: "Nonprofits", icon: NonprofitsIcon, hint: "Assigned organizations", group: "config" }, + { slug: "media", label: "Media", icon: MediaIcon, hint: "Photos and social posts", group: "config" }, + { slug: "planning", label: "Planning", icon: PlanningIcon, hint: "Planning board, Slack, editors", group: "config" }, + { slug: "donations", label: "Funding", icon: DonationsIcon, hint: "Donation totals and goals", group: "config" }, + { slug: "links", label: "Links", icon: LinksIcon, hint: "Buttons shown on the public event page", group: "config" }, + { slug: "volunteer", label: "Volunteer admin", icon: VolunteerAdminIcon, hint: "Mentors, judges, volunteers, hackers, sponsors", group: "ops" }, + { slug: "teams", label: "Teams", icon: TeamsIcon, hint: "Team rosters and nonprofit assignments", group: "ops" }, + { slug: "judging", label: "Judging", icon: JudgingIcon, hint: "Round 1, Round 2, and results", group: "ops" }, + { slug: "checkin", label: "Check-in", icon: CheckInIcon, hint: "QR scan and manual check-in at the event", group: "ops" }, +]; + +export const DEFAULT_SECTION = "overview"; + +export const isValidSection = (slug) => SECTIONS.some((s) => s.slug === slug); diff --git a/src/components/admin/hackathon-edit/useHackathonAdmin.js b/src/components/admin/hackathon-edit/useHackathonAdmin.js new file mode 100644 index 00000000..6251be9b --- /dev/null +++ b/src/components/admin/hackathon-edit/useHackathonAdmin.js @@ -0,0 +1,344 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +const SAVE_DEBOUNCE_MS = 1500; + +const cloneDeep = (v) => + typeof structuredClone === "function" ? structuredClone(v) : JSON.parse(JSON.stringify(v)); + +// Sections that get explicit Save / Discard. Everything else autosaves. +export const EXPLICIT_SAVE_SECTIONS = new Set([ + "overview-dates", + "schedule", + "meals", + "screening", + "deposit", + "nonprofits", +]); + +export const SECTION_LABELS = { + "overview-dates": "Overview (dates)", + schedule: "Schedule", + meals: "Meals", + screening: "Screening Questions", + deposit: "Deposit", + nonprofits: "Nonprofits", +}; + +const buildHeaders = (accessToken, orgId) => ({ + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + ...(orgId ? { "X-Org-Id": orgId } : {}), +}); + +/** + * Loads a hackathon by event_id and exposes the editing state machine for the + * per-event admin page. Holds two shapes of state: + * - draft: the in-memory edit (what the form renders from) + * - committed: last server-confirmed version (used to compute autosave diffs + * and tell whether explicit-save sections are dirty) + * + * Hybrid save model: + * - Autosave fires on draft changes after debounce, BUT only for keys not + * touched in any explicit-save section currently marked dirty. + * - Explicit-save sections call commitSection(name) to flush. + */ +export function useHackathonAdmin({ eventId, accessToken, orgId, isAdmin }) { + const [draft, setDraft] = useState(null); + const [committed, setCommitted] = useState(null); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [saveState, setSaveState] = useState({ status: "idle", lastSavedAt: null, error: null }); + const [dirtySections, setDirtySections] = useState(new Set()); + const saveTimerRef = useRef(null); + const inflightRef = useRef(null); + // PropelAuth mints a fresh accessToken when the tab regains focus. Requests + // read the token through refs so fetch/patch callbacks stay identity-stable + // across token rotation — otherwise the load effect refires, flips + // `loading`, unmounts the whole section tree behind the page spinner, and + // clobbers unsaved draft edits. + const accessTokenRef = useRef(accessToken); + accessTokenRef.current = accessToken; + const orgIdRef = useRef(orgId); + orgIdRef.current = orgId; + const loadedEventIdRef = useRef(null); + + const apiBase = process.env.NEXT_PUBLIC_API_SERVER_URL; + + const fetchHackathon = useCallback(async () => { + if (!isAdmin || !eventId || !accessTokenRef.current) return; + // Switching to a different event: drop the stale draft so the page shows + // the loader instead of the previous event's data. + if (loadedEventIdRef.current !== eventId) { + setDraft(null); + setCommitted(null); + } + setLoading(true); + setLoadError(null); + try { + const res = await fetch(`${apiBase}/api/messages/hackathons`, { + headers: buildHeaders(accessTokenRef.current, orgIdRef.current), + }); + if (!res.ok) throw new Error(`Failed to load hackathons (${res.status})`); + const data = await res.json(); + // Prefer event_id (the slug) but fall back to the Firestore doc id — + // legacy hackathons + some of the per-event admin shortcut buttons end + // up routing here with the doc id instead of the slug. + const list = data.hackathons || []; + const found = + list.find((h) => h.event_id === eventId) || + list.find((h) => h.id === eventId); + if (!found) { + setLoadError(`No hackathon with event_id "${eventId}"`); + setDraft(null); + setCommitted(null); + return; + } + const normalized = { + ...found, + start_date: found.start_date ? String(found.start_date).split("T")[0] : "", + end_date: found.end_date ? String(found.end_date).split("T")[0] : "", + countdowns: found.countdowns || [], + links: found.links || [], + event_photos: found.event_photos || [], + social_posts: found.social_posts || [], + constraints: found.constraints || {}, + donation_current: found.donation_current || { food: "0", prize: "0", swag: "0", thank_you: "" }, + donation_goals: found.donation_goals || { food: "0", prize: "0", swag: "0" }, + planning: found.planning || {}, + }; + setDraft(normalized); + setCommitted(cloneDeep(normalized)); + loadedEventIdRef.current = eventId; + } catch (err) { + console.error("Hackathon load failed:", err); + setLoadError(err.message || "Failed to load"); + } finally { + setLoading(false); + } + }, [apiBase, eventId, isAdmin]); + + // Keyed on token *presence*, not value — a rotated token must not reload. + const hasToken = !!accessToken; + useEffect(() => { + if (!hasToken) return; + fetchHackathon(); + }, [fetchHackathon, hasToken]); + + // Push a PATCH with the given payload. Returns the saved hackathon. + const pushPatch = useCallback( + async (payload) => { + // Cancel any in-flight to avoid stale overwrites; fetch doesn't natively + // cancel but we can ignore the response. + const myToken = Symbol("save"); + inflightRef.current = myToken; + const res = await fetch(`${apiBase}/api/messages/hackathon`, { + method: "PATCH", + headers: buildHeaders(accessTokenRef.current, orgIdRef.current), + body: JSON.stringify(payload), + }); + if (inflightRef.current !== myToken) return null; + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(text || `Save failed (${res.status})`); + } + return payload; + }, + [apiBase] + ); + + // Build a payload that contains the committed state plus only the keys not + // touched by any currently-dirty explicit-save section. (This preserves the + // user's expectation that an explicit-save section won't commit until Save.) + // + // Special case: if "overview-dates" is dirty, skip autosave entirely (return + // null). Its fields (start_date, end_date, timezone) are required by the + // backend AND cross-field-validated. Sending stale committed values can fail + // the "end_date > start_date" check; omitting them causes "Missing required + // field". The explicit Save button is the only safe flush path for this section. + const buildAutosavePayload = useCallback(() => { + if (!draft || !committed) return null; + if (dirtySections.has("overview-dates")) return null; + const dirtyKeys = collectKeysForSections(dirtySections); + const merged = { ...committed }; + Object.keys(draft).forEach((k) => { + if (!dirtyKeys.has(k)) merged[k] = draft[k]; + }); + return merged; + }, [draft, committed, dirtySections]); + + // Trigger autosave when draft changes (and there are committed-vs-draft diffs + // outside of any dirty explicit-save section). + useEffect(() => { + if (!draft || !committed) return; + if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + const payload = buildAutosavePayload(); + if (!payload) return; + if (JSON.stringify(payload) === JSON.stringify(committed)) return; + + saveTimerRef.current = setTimeout(async () => { + setSaveState({ status: "saving", lastSavedAt: null, error: null }); + try { + const saved = await pushPatch(payload); + if (saved) { + setCommitted(cloneDeep(saved)); + setSaveState({ status: "saved", lastSavedAt: Date.now(), error: null }); + } + } catch (err) { + console.error("Autosave failed:", err); + setSaveState({ status: "error", lastSavedAt: null, error: err.message }); + } + }, SAVE_DEBOUNCE_MS); + return () => clearTimeout(saveTimerRef.current); + }, [draft, committed, dirtySections, buildAutosavePayload, pushPatch]); + + // Field-update helpers ---------------------------------------------------- + const setField = useCallback((field, value) => { + setDraft((prev) => (prev ? { ...prev, [field]: value } : prev)); + }, []); + + const setConstraint = useCallback((field, value) => { + setDraft((prev) => + prev ? { ...prev, constraints: { ...(prev.constraints || {}), [field]: value } } : prev + ); + }, []); + + const setPlanning = useCallback((patch) => { + setDraft((prev) => + prev ? { ...prev, planning: { ...(prev.planning || {}), ...patch } } : prev + ); + }, []); + + const setDeposit = useCallback((field, value) => { + setDraft((prev) => { + if (!prev) return prev; + const cur = prev.constraints?.hacker_deposit || { enabled: false, default_amount_cents: 500 }; + return { + ...prev, + constraints: { ...(prev.constraints || {}), hacker_deposit: { ...cur, [field]: value } }, + }; + }); + }, []); + + // Mark + commit explicit-save sections ----------------------------------- + const markSectionDirty = useCallback((section, dirty) => { + setDirtySections((prev) => { + const next = new Set(prev); + if (dirty) next.add(section); + else next.delete(section); + return next; + }); + }, []); + + const commitSection = useCallback( + async (section) => { + if (!draft) return { ok: false, error: "No draft" }; + // Cancel any pending autosave timer so it can't fire mid-flight and + // overwrite inflightRef, which would cause the explicit save to return + // null (treated as cancelled) while the autosave wins with stale data. + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; + } + setSaveState({ status: "saving", lastSavedAt: null, error: null }); + try { + const saved = await pushPatch(draft); + if (saved) { + setCommitted(cloneDeep(saved)); + setSaveState({ status: "saved", lastSavedAt: Date.now(), error: null }); + markSectionDirty(section, false); + return { ok: true }; + } + return { ok: false, error: "Concurrent save" }; + } catch (err) { + setSaveState({ status: "error", lastSavedAt: null, error: err.message }); + return { ok: false, error: err.message }; + } + }, + [draft, pushPatch, markSectionDirty] + ); + + const discardSection = useCallback( + (section) => { + if (!committed) return; + const keys = collectKeysForSections(new Set([section])); + setDraft((prev) => { + if (!prev) return prev; + const next = { ...prev }; + keys.forEach((k) => { + next[k] = cloneDeep(committed[k]); + }); + return next; + }); + markSectionDirty(section, false); + }, + [committed, markSectionDirty] + ); + + // Beforeunload guard for any unsaved explicit work + useEffect(() => { + const handler = (e) => { + if (dirtySections.size === 0) return; + e.preventDefault(); + e.returnValue = ""; + }; + window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); + }, [dirtySections]); + + const value = useMemo( + () => ({ + hackathon: draft, + committed, + loading, + loadError, + saveState, + dirtySections, + setField, + setConstraint, + setPlanning, + setDeposit, + markSectionDirty, + commitSection, + discardSection, + refetch: fetchHackathon, + }), + [ + draft, + committed, + loading, + loadError, + saveState, + dirtySections, + setField, + setConstraint, + setPlanning, + setDeposit, + markSectionDirty, + commitSection, + discardSection, + fetchHackathon, + ] + ); + + return value; +} + +// Map an explicit-save section name to the top-level draft keys it owns. When +// any of these sections are dirty we exclude their owned keys from autosave. +function collectKeysForSections(sectionSet) { + const keys = new Set(); + sectionSet.forEach((s) => { + if (s === "overview-dates") { + keys.add("start_date"); + keys.add("end_date"); + keys.add("timezone"); + } else if (s === "schedule") { + keys.add("countdowns"); + } else if (s === "meals" || s === "screening" || s === "deposit") { + keys.add("constraints"); + } else if (s === "nonprofits") { + // nonprofits has its own endpoint inside NonprofitManagement + } + }); + return keys; +} diff --git a/src/components/admin/praisebot/CalendarReminderEditDialog.js b/src/components/admin/praisebot/CalendarReminderEditDialog.js new file mode 100644 index 00000000..a67fc4f3 --- /dev/null +++ b/src/components/admin/praisebot/CalendarReminderEditDialog.js @@ -0,0 +1,157 @@ +import React, { useState, useEffect } from "react"; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + Button, + Box, + FormControlLabel, + Switch, +} from "@mui/material"; +import CronInput, { isValidCron } from "./CronInput"; + +const EMPTY_FORM = { + name: "", + enabled: true, + calendar_id: "", + channels: "", + lead_minutes: 15, + events_page_url: "https://www.ohack.dev/office-hours", + poll_cron: "*/5 * * * *", +}; + +const toForm = (reminder) => ({ + name: reminder.name || "", + enabled: reminder.enabled !== false, + calendar_id: reminder.calendar_id || "", + channels: (reminder.channels || []).join(", "), + lead_minutes: reminder.lead_minutes || 15, + events_page_url: reminder.events_page_url || "", + poll_cron: reminder.poll_cron || "*/5 * * * *", +}); + +const CalendarReminderEditDialog = ({ open, onClose, reminder, onSave }) => { + const [form, setForm] = useState(EMPTY_FORM); + const [errors, setErrors] = useState({}); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + setForm(reminder ? toForm(reminder) : EMPTY_FORM); + setErrors({}); + }, [reminder, open]); + + const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value })); + + const validate = () => { + const next = {}; + if (!form.name.trim()) next.name = "Name is required"; + if (!form.calendar_id.trim()) next.calendar_id = "Google Calendar ID is required"; + if (!form.channels.trim()) next.channels = "At least one channel is required"; + const lead = parseInt(form.lead_minutes, 10); + if (!Number.isInteger(lead) || lead < 1 || lead > 240) { + next.lead_minutes = "Must be between 1 and 240 minutes"; + } + if (!isValidCron(form.poll_cron)) next.poll_cron = true; + setErrors(next); + return Object.keys(next).length === 0; + }; + + const handleSave = async () => { + if (!validate()) return; + setIsSubmitting(true); + try { + await onSave({ + id: reminder?.id, + type: "calendar_reminder", + name: form.name.trim(), + enabled: form.enabled, + calendar_id: form.calendar_id.trim(), + channels: form.channels.split(",").map((s) => s.trim()).filter(Boolean), + lead_minutes: parseInt(form.lead_minutes, 10), + events_page_url: form.events_page_url.trim(), + poll_cron: form.poll_cron.trim(), + }); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + {reminder ? "Edit Calendar Reminder" : "Add Calendar Reminder"} + + + + + set("name")(e.target.value)} + error={Boolean(errors.name)} + helperText={errors.name} + sx={{ flexGrow: 1 }} + /> + set("enabled")(e.target.checked)} + /> + } + label="Enabled" + /> + + set("calendar_id")(e.target.value)} + error={Boolean(errors.calendar_id)} + helperText={ + errors.calendar_id || + "Paste the calendar ID (c_…@group.calendar.google.com) or any Google Calendar share/embed/ICS link — the ID is extracted on save. The calendar must be public." + } + /> + set("channels")(e.target.value)} + error={Boolean(errors.channels)} + helperText={errors.channels} + /> + set("lead_minutes")(e.target.value)} + error={Boolean(errors.lead_minutes)} + helperText={errors.lead_minutes} + sx={{ maxWidth: 280 }} + /> + set("events_page_url")(e.target.value)} + /> + + + + + + + + + ); +}; + +export default CalendarReminderEditDialog; diff --git a/src/components/admin/praisebot/CronInput.js b/src/components/admin/praisebot/CronInput.js new file mode 100644 index 00000000..89f60dca --- /dev/null +++ b/src/components/admin/praisebot/CronInput.js @@ -0,0 +1,53 @@ +import React from "react"; +import { Box, TextField, Select, MenuItem, FormControl, InputLabel } from "@mui/material"; + +// Cron entry with common presets. Times are UTC unless a timezone is set in +// the Global tab (Arizona = UTC-7 year-round, so 9 AM AZ = 16:00 UTC). +export const CRON_PRESETS = [ + { label: "Daily 9am Arizona", value: "0 16 * * *" }, + { label: "Weekdays 8am Arizona", value: "0 15 * * 1-5" }, + { label: "Weekly Monday 9am Arizona", value: "0 16 * * 1" }, + { label: "Every 5 minutes", value: "*/5 * * * *" }, +]; + +export const isValidCron = (value) => + typeof value === "string" && value.trim().split(/\s+/).length === 5; + +const CronInput = ({ label, value, onChange, error, helperText }) => { + const presetMatch = CRON_PRESETS.find((p) => p.value === value); + + return ( + + + {label} preset + + + onChange(e.target.value)} + error={error} + helperText={ + helperText || + (error ? "Must be a 5-field cron expression" : "min hour day month weekday") + } + sx={{ flexGrow: 1 }} + /> + + ); +}; + +export default CronInput; diff --git a/src/components/admin/praisebot/GithubWatcherEditDialog.js b/src/components/admin/praisebot/GithubWatcherEditDialog.js new file mode 100644 index 00000000..486aa961 --- /dev/null +++ b/src/components/admin/praisebot/GithubWatcherEditDialog.js @@ -0,0 +1,276 @@ +import React, { useState, useEffect } from "react"; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + Button, + Box, + Typography, + FormControlLabel, + Switch, + Radio, + RadioGroup, + FormControl, + FormLabel, + Select, + MenuItem, + InputLabel, + Divider, +} from "@mui/material"; +import CronInput, { isValidCron } from "./CronInput"; + +const EMPTY_FORM = { + name: "", + enabled: true, + mode: "repos", + event_id: "", + repos: "", + channels: "", + digest_enabled: true, + digest_cron: "0 16 * * *", + rollup_enabled: false, + rollup_cron: "0 16 * * 1", + rollup_channel: "", + dry_run: false, +}; + +const toForm = (watcher) => ({ + name: watcher.name || "", + enabled: watcher.enabled !== false, + mode: watcher.source?.mode || "repos", + event_id: watcher.source?.event_id || "", + repos: (watcher.source?.repos || []).join("\n"), + channels: (watcher.source?.channels || []).join(", "), + digest_enabled: Boolean(watcher.digest?.enabled), + digest_cron: watcher.digest?.cron || "0 16 * * *", + rollup_enabled: Boolean(watcher.rollup?.enabled), + rollup_cron: watcher.rollup?.cron || "0 16 * * 1", + rollup_channel: watcher.rollup?.channel || "", + dry_run: Boolean(watcher.dry_run), +}); + +const GithubWatcherEditDialog = ({ open, onClose, watcher, hackathons, onSave }) => { + const [form, setForm] = useState(EMPTY_FORM); + const [errors, setErrors] = useState({}); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + setForm(watcher ? toForm(watcher) : EMPTY_FORM); + setErrors({}); + }, [watcher, open]); + + const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value })); + + const validate = () => { + const next = {}; + if (!form.name.trim()) next.name = "Name is required"; + if (form.mode === "hackathon" && !form.event_id) { + next.event_id = "Pick a hackathon event"; + } + if (form.mode === "repos") { + if (!form.repos.trim()) next.repos = "At least one repo is required"; + if (!form.channels.trim()) next.channels = "At least one channel is required"; + } + if (form.digest_enabled && !isValidCron(form.digest_cron)) next.digest_cron = true; + if (form.rollup_enabled) { + if (!isValidCron(form.rollup_cron)) next.rollup_cron = true; + if (!form.rollup_channel.trim()) next.rollup_channel = "Rollup channel is required"; + } + setErrors(next); + return Object.keys(next).length === 0; + }; + + const handleSave = async () => { + if (!validate()) return; + setIsSubmitting(true); + try { + const source = + form.mode === "hackathon" + ? { mode: "hackathon", event_id: form.event_id } + : { + mode: "repos", + repos: form.repos.split(/\n|,/).map((s) => s.trim()).filter(Boolean), + channels: form.channels.split(",").map((s) => s.trim()).filter(Boolean), + }; + await onSave({ + id: watcher?.id, + type: "github_watcher", + name: form.name.trim(), + enabled: form.enabled, + source, + digest: { enabled: form.digest_enabled, cron: form.digest_cron.trim() }, + rollup: { + enabled: form.rollup_enabled, + cron: form.rollup_cron.trim(), + channel: form.rollup_channel.trim(), + }, + dry_run: form.dry_run, + }); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + {watcher ? "Edit GitHub Watcher" : "Add GitHub Watcher"} + + + + set("name")(e.target.value)} + error={Boolean(errors.name)} + helperText={errors.name} + sx={{ flexGrow: 1 }} + /> + set("enabled")(e.target.checked)} + /> + } + label="Enabled" + /> + + + + Source + set("mode")(e.target.value)} + > + } + label="Explicit repo list" + /> + } + label="Hackathon teams" + /> + + + + {form.mode === "hackathon" ? ( + + Hackathon event + + + Teams, repos, and channels are pulled live from this event's + active teams — same behavior as the original bot. + + + ) : ( + <> + set("repos")(e.target.value)} + error={Boolean(errors.repos)} + helperText={errors.repos} + multiline + minRows={3} + /> + set("channels")(e.target.value)} + error={Boolean(errors.channels)} + helperText={ + errors.channels || + "Channel IDs (C0123…) are more reliable than names in large workspaces" + } + /> + + )} + + + + set("digest_enabled")(e.target.checked)} + /> + } + label="Daily digest" + sx={{ minWidth: 140 }} + /> + + {form.digest_enabled && ( + + )} + + + set("rollup_enabled")(e.target.checked)} + /> + } + label="Mentor rollup (cross-team health summary)" + /> + {form.rollup_enabled && ( + <> + + set("rollup_channel")(e.target.value)} + error={Boolean(errors.rollup_channel)} + helperText={errors.rollup_channel} + /> + + )} + + + set("dry_run")(e.target.checked)} + /> + } + label="Dry run (log to bot console instead of posting to Slack)" + /> + + + + + + + + ); +}; + +export default GithubWatcherEditDialog; diff --git a/src/components/admin/volunteer/VolunteerWorkbench.js b/src/components/admin/volunteer/VolunteerWorkbench.js new file mode 100644 index 00000000..b3e696fe --- /dev/null +++ b/src/components/admin/volunteer/VolunteerWorkbench.js @@ -0,0 +1,1811 @@ +import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; +import { useAuthInfo, withRequiredAuthInfo } from "@propelauth/react"; +import { useRouter } from "next/router"; +import Head from "next/head"; +import { + Box, + Grid, + CircularProgress, + TextField, + Button, + Tabs, + Tab, + FormControl, + InputLabel, + Select, + MenuItem, + Badge, + ToggleButton, + ToggleButtonGroup, + IconButton, + Tooltip, + Alert, + Snackbar as MuiSnackbar, + Typography, + useTheme, + useMediaQuery, + Stack, + Collapse, + Card, + CardContent, +} from "@mui/material"; +import { Share as ShareIcon, ContentCopy as CopyIcon } from "@mui/icons-material"; + +// Import components individually to avoid circular dependencies +import AdminPage from "../../../components/admin/AdminPage"; +import VolunteerTable from "../../../components/admin/VolunteerTable"; +import VolunteerEditDialog from "../../../components/admin/VolunteerEditDialog"; +import ApplicationReviewList from "../../../components/admin/ApplicationReviewList"; +import ApplicationEditDialog from "../../../components/admin/ApplicationEditDialog"; +import VolunteerCommunication from "../../../components/admin/VolunteerCommunication"; +import SlackInviteDialog from "../../../components/admin/SlackInviteDialog"; +import BatchEmailDialog from "../../../components/admin/BatchEmailDialog"; +import BulkCertificateDialog from "../../../components/admin/BulkCertificateDialog"; +import HackerDepositRefundDialog from "../../../components/admin/HackerDepositRefundDialog"; +import HackerDepositBulkRefundDialog from "../../../components/admin/HackerDepositBulkRefundDialog"; +import { getDepositState } from "../../../components/admin/HackerDepositChip"; +import useHackathonEvents from "../../../hooks/use-hackathon-events"; + +// Define initial state outside component to prevent re-initialization +const INITIAL_VOLUNTEERS_STATE = { + mentors: [], + judges: [], + volunteers: [], + hackers: [], + sponsors: [] +}; + +const INITIAL_SNACKBAR_STATE = { + open: false, + message: "", + severity: "success", +}; + +// Move this function outside component to avoid circular dependency issues +const getCurrentVolunteerType = (currentTabValue) => { + switch (currentTabValue) { + case 0: + return "mentors"; + case 1: + return "judges"; + case 2: + return "volunteers"; + case 3: + return "hackers"; + case 4: + return "sponsors"; + default: + return ""; + } +}; + +const getCurrentVolunteerTypeSingular = (currentTabValue) => { + switch (currentTabValue) { + case 0: + return "mentor"; + case 1: + return "judge"; + case 2: + return "volunteer"; + case 3: + return "hacker"; + case 4: + return "sponsor"; + default: + return ""; + } +}; + +// When `embedded` is true, this component is being rendered inside the +// per-event hackathon admin page (/admin/hackathons/[event_id]?section=volunteer). +// In that mode we skip the standalone AdminPage chrome, hide the in-page event +// picker (the sidebar already owns event context), and source the event id +// from `externalEventId` instead of URL/state. +const VolunteerWorkbench = ({ userClass, embedded = false, externalEventId, onSnack }) => { + // Early returns for safety + if (!userClass) { + return
Loading...
; + } + + const { accessToken } = useAuthInfo(); + const router = useRouter(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('md')); + const isSmallMobile = useMediaQuery(theme.breakpoints.down('sm')); + + // Defensive hook usage with fallbacks + const { hackathons = [] } = useHackathonEvents(false) || {}; + + const [volunteers, setVolunteers] = useState(INITIAL_VOLUNTEERS_STATE); + const [loading, setLoading] = useState(false); + const [snackbar, setSnackbar] = useState(INITIAL_SNACKBAR_STATE); + const [orderBy, setOrderBy] = useState("name"); + const [order, setOrder] = useState("asc"); + const [filter, setFilter] = useState(""); + const [tabValue, setTabValue] = useState(0); + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [editingVolunteer, setEditingVolunteer] = useState(null); + const [isAdding, setIsAdding] = useState(false); + const [selectedEventId, setSelectedEventId] = useState(""); + // Full event doc for the selected event — used to read constraints + // (currently just hacker_deposit.enabled). Fetched separately because the + // hackathons LIST endpoint doesn't include the `constraints` blob. + const [selectedEvent, setSelectedEvent] = useState(null); + const [depositDialogVolunteer, setDepositDialogVolunteer] = useState(null); + const [bulkRefundDialogOpen, setBulkRefundDialogOpen] = useState(false); + const [viewMode, setViewMode] = useState("table"); // "table" or "review" + const [applicationEditDialogOpen, setApplicationEditDialogOpen] = useState(false); + const [editingApplication, setEditingApplication] = useState(null); + const [communicationDialogOpen, setCommunicationDialogOpen] = useState(false); + const [selectedVolunteerForMessage, setSelectedVolunteerForMessage] = useState(null); + const [slackInviteDialogOpen, setSlackInviteDialogOpen] = useState(false); + const [volunteersForSlackInvite, setVolunteersForSlackInvite] = useState([]); + const [volunteerTypeForSlackInvite, setVolunteerTypeForSlackInvite] = useState(''); + const [batchEmailDialogOpen, setBatchEmailDialogOpen] = useState(false); + const [volunteersForBatchEmail, setVolunteersForBatchEmail] = useState([]); + const [volunteerTypeForBatchEmail, setVolunteerTypeForBatchEmail] = useState(''); + const [isSelectedUsersForEmail, setIsSelectedUsersForEmail] = useState(true); + const [bulkCertificateDialogOpen, setBulkCertificateDialogOpen] = useState(false); + const [volunteersForBulkCertificate, setVolunteersForBulkCertificate] = useState([]); + const [volunteerTypeForBulkCertificate, setVolunteerTypeForBulkCertificate] = useState(''); + const [shareSnackbar, setShareSnackbar] = useState({ open: false, message: '' }); + + // Filter state management + const [filterStates, setFilterStates] = useState({ + mentors: { + filter: '', + statusFilter: 'all', + inPersonFilter: 'all', + checkedInFilter: 'all', + sortBy: 'timestamp', + sortOrder: 'desc', + showBatchActions: false + }, + judges: { + filter: '', + statusFilter: 'all', + inPersonFilter: 'all', + checkedInFilter: 'all', + sortBy: 'timestamp', + sortOrder: 'desc', + showBatchActions: false + }, + volunteers: { + filter: '', + statusFilter: 'all', + inPersonFilter: 'all', + checkedInFilter: 'all', + sortBy: 'timestamp', + sortOrder: 'desc', + showBatchActions: false + }, + hackers: { + filter: '', + statusFilter: 'all', + inPersonFilter: 'all', + checkedInFilter: 'all', + sortBy: 'timestamp', + sortOrder: 'desc', + showBatchActions: false + }, + sponsors: { + filter: '', + statusFilter: 'all', + inPersonFilter: 'all', + checkedInFilter: 'all', + sortBy: 'timestamp', + sortOrder: 'desc', + showBatchActions: false + } + }); + + // Refs for scroll position preservation + const scrollContainerRef = useRef(null); + const savedScrollPositionRef = useRef(0); + const lastUrlRef = useRef(''); + const dataLoadedRef = useRef(false); + const initializedFromUrlRef = useRef(false); + + // Save scroll position when navigating away and restore when returning + useEffect(() => { + const handleBeforeUnload = () => { + if (scrollContainerRef.current) { + savedScrollPositionRef.current = scrollContainerRef.current.scrollTop; + // Store in sessionStorage as a backup + sessionStorage.setItem('volunteer-admin-scroll', savedScrollPositionRef.current.toString()); + } + }; + + const handleVisibilityChange = () => { + if (document.hidden && scrollContainerRef.current) { + savedScrollPositionRef.current = scrollContainerRef.current.scrollTop; + sessionStorage.setItem('volunteer-admin-scroll', savedScrollPositionRef.current.toString()); + } else if (!document.hidden && scrollContainerRef.current) { + // Restore scroll position when page becomes visible again + const savedPosition = savedScrollPositionRef.current || + parseInt(sessionStorage.getItem('volunteer-admin-scroll') || '0', 10); + + if (savedPosition > 0) { + setTimeout(() => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = savedPosition; + savedScrollPositionRef.current = savedPosition; + } + }, 100); + } + } + }; + + const handlePopState = () => { + // Handle browser back/forward navigation + const savedPosition = parseInt(sessionStorage.getItem('volunteer-admin-scroll') || '0', 10); + if (savedPosition > 0 && scrollContainerRef.current) { + setTimeout(() => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = savedPosition; + } + }, 100); + } + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + document.addEventListener('visibilitychange', handleVisibilityChange); + window.addEventListener('popstate', handlePopState); + + // Restore scroll position on initial load if available + const savedPosition = parseInt(sessionStorage.getItem('volunteer-admin-scroll') || '0', 10); + if (savedPosition > 0) { + setTimeout(() => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = savedPosition; + } + }, 200); + } + + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); + document.removeEventListener('visibilitychange', handleVisibilityChange); + window.removeEventListener('popstate', handlePopState); + }; + }, []); + + // Defensive org access with null checks + const org = userClass?.getOrgByName?.("Opportunity Hack Org"); + const isAdmin = org?.hasPermission?.("volunteer.admin") ?? false; + const orgId = org?.orgId; + + // When embedded, pre-seed the event id from the parent's sidebar context + // and never write back to the URL — the host page owns the URL contract. + // (Declared before the !isAdmin early return to keep hook order stable.) + useEffect(() => { + if (!embedded) return; + if (!externalEventId) return; + if (selectedEventId === externalEventId) return; + setSelectedEventId(externalEventId); + initializedFromUrlRef.current = true; + }, [embedded, externalEventId, selectedEventId]); + + // Early return if not admin to prevent further execution + if (!isAdmin) { + if (embedded) return null; + return ( + + You do not have permission to view this page. + + ); + } + + // Handle URL parameters and set initial state with defensive checks + // Only runs for initialization — once event ID is set from URL, stops overriding user selections + useEffect(() => { + if (!router?.query) return; + if (!Array.isArray(hackathons) || hackathons.length === 0) return; + if (initializedFromUrlRef.current) return; + + const { + event_id, + tab, + filter, + statusFilter, + inPersonFilter, + checkedInFilter, + sortBy, + sortOrder, + showBatchActions, + volunteer_id, + volunteer_type + } = router.query; + + if (event_id && hackathons.some(h => h?.event_id === event_id)) { + setSelectedEventId(event_id); + } else { + // Sort hackathons by date (descending) and use the most recent one + const sortedHackathons = [...hackathons] + .filter(h => h?.start_date) // Filter out invalid entries + .sort((a, b) => { + const dateA = new Date(a.start_date); + const dateB = new Date(b.start_date); + return dateB - dateA; // Most recent first + }); + + if (sortedHackathons.length > 0) { + setSelectedEventId(sortedHackathons[0].event_id); + } + } + + initializedFromUrlRef.current = true; + + // Handle volunteer_type parameter to set correct tab + if (volunteer_type && !tab) { + const typeToTabMap = { + mentor: 0, + judge: 1, + volunteer: 2, + hacker: 3, + sponsor: 4 + }; + if (typeToTabMap[volunteer_type] !== undefined) { + setTabValue(typeToTabMap[volunteer_type]); + } + } else if (tab !== undefined) { + const tabIndex = parseInt(tab, 10); + if (tabIndex >= 0 && tabIndex <= 4) { + setTabValue(tabIndex); + } + } + + // Handle filter state restoration and volunteer_id filtering + const finalTabValue = volunteer_type && !tab ? + ({ mentor: 0, judge: 1, volunteer: 2, hacker: 3, sponsor: 4 })[volunteer_type] || tabValue : + (tab !== undefined ? parseInt(tab, 10) : tabValue); + + if (finalTabValue >= 0 && finalTabValue <= 4) { + const currentType = getCurrentVolunteerType(finalTabValue); + if (currentType) { + const filterToApply = volunteer_id ? volunteer_id : (filter || ''); + + if (filterToApply || statusFilter || inPersonFilter || checkedInFilter || sortBy || sortOrder || showBatchActions) { + setFilterStates(prev => ({ + ...prev, + [currentType]: { + filter: filterToApply, + statusFilter: statusFilter || 'all', + inPersonFilter: inPersonFilter || 'all', + checkedInFilter: checkedInFilter || 'all', + sortBy: sortBy || 'timestamp', + sortOrder: sortOrder || 'desc', + showBatchActions: showBatchActions === 'true' + } + })); + } + + // If we have volunteer_id, also set the global filter + if (volunteer_id) { + setFilter(volunteer_id); + } + } + } + }, [hackathons, router?.query]); + + // Update URL when selectedEventId, tabValue, or filter states change + useEffect(() => { + if (embedded) return; // host page owns the URL when embedded + if (!router?.replace || !selectedEventId || !Array.isArray(hackathons) || hackathons.length === 0) { + return; + } + + try { + const currentFilterState = getCurrentFilterState(); + + // Build query parameters + const queryParams = new URLSearchParams(); + queryParams.set('event_id', selectedEventId); + queryParams.set('tab', tabValue.toString()); + + // Add filter parameters if they differ from defaults + if (currentFilterState.filter) { + queryParams.set('filter', currentFilterState.filter); + } + if (currentFilterState.statusFilter !== 'all') { + queryParams.set('statusFilter', currentFilterState.statusFilter); + } + if (currentFilterState.inPersonFilter !== 'all') { + queryParams.set('inPersonFilter', currentFilterState.inPersonFilter); + } + if (currentFilterState.checkedInFilter !== 'all') { + queryParams.set('checkedInFilter', currentFilterState.checkedInFilter); + } + if (currentFilterState.sortBy !== 'timestamp') { + queryParams.set('sortBy', currentFilterState.sortBy); + } + if (currentFilterState.sortOrder !== 'desc') { + queryParams.set('sortOrder', currentFilterState.sortOrder); + } + if (currentFilterState.showBatchActions) { + queryParams.set('showBatchActions', 'true'); + } + + const newUrl = `${router.pathname}?${queryParams.toString()}`; + + // Only update URL if it's actually different to prevent unnecessary re-renders + if (lastUrlRef.current !== newUrl) { + lastUrlRef.current = newUrl; + + const newQuery = Object.fromEntries(queryParams); + router.replace({ + pathname: router.pathname, + query: newQuery + }, undefined, { shallow: true }); + } + } catch (error) { + console.warn('Failed to update URL:', error); + } + }, [selectedEventId, tabValue, filterStates, router, hackathons]); + + const fetchVolunteers = useCallback(async () => { + if (!selectedEventId || !accessToken || !orgId) { + console.warn('Missing required parameters for fetchVolunteers'); + return; + } + + setLoading(true); + try { + const baseUrl = process.env.NEXT_PUBLIC_API_SERVER_URL; + if (!baseUrl) { + throw new Error('API server URL not configured'); + } + + const endpoints = [ + `${baseUrl}/api/messages/admin/hackathon/${selectedEventId}/mentor`, + `${baseUrl}/api/messages/admin/hackathon/${selectedEventId}/judge`, + `${baseUrl}/api/messages/admin/hackathon/${selectedEventId}/volunteer`, + `${baseUrl}/api/messages/admin/hackathon/${selectedEventId}/hacker`, + `${baseUrl}/api/messages/admin/hackathon/${selectedEventId}/sponsor`, + ]; + + const headers = { + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + "X-Org-Id": orgId, + }; + + const responses = await Promise.allSettled( + endpoints.map(url => fetch(url, { headers })) + ); + + // Process responses safely + const responseData = { ...INITIAL_VOLUNTEERS_STATE }; + const keys = ['mentors', 'judges', 'volunteers', 'hackers', 'sponsors']; + + for (let i = 0; i < responses.length; i++) { + const result = responses[i]; + const key = keys[i]; + + if (result.status === 'fulfilled' && result.value?.ok) { + try { + const data = await result.value.json(); + responseData[key] = Array.isArray(data?.data) ? data.data : []; + } catch (jsonError) { + console.warn(`Failed to parse JSON for ${key}:`, jsonError); + responseData[key] = []; + } + } else { + console.warn(`Failed to fetch ${key}:`, result.reason || 'Unknown error'); + responseData[key] = []; + } + } + + setVolunteers(responseData); + + } catch (error) { + console.error("Error fetching volunteers:", error); + setSnackbar({ + open: true, + message: "Failed to fetch volunteers. Please try again.", + severity: "error", + }); + } finally { + setLoading(false); + } + }, [accessToken, orgId, selectedEventId]); + + useEffect(() => { + if (isAdmin && selectedEventId && accessToken && orgId) { + // Only fetch if we don't already have data for this event, or if data is explicitly stale + const hasCurrentEventData = volunteers.mentors.length > 0 || + volunteers.judges.length > 0 || + volunteers.volunteers.length > 0 || + volunteers.hackers.length > 0 || + volunteers.sponsors.length > 0; + + if (!dataLoadedRef.current || !hasCurrentEventData) { + fetchVolunteers(); + dataLoadedRef.current = true; + } + } + }, [isAdmin, selectedEventId, accessToken, orgId]); + + // Load the full event doc so we can read constraints.hacker_deposit.enabled. + // The hackathons list endpoint omits `constraints`, so we fetch the event + // individually. Reset on event change so a stale `selectedEvent` from the + // previous event can't leak the wrong deposit column visibility. + useEffect(() => { + if (!selectedEventId) { + setSelectedEvent(null); + return; + } + let cancelled = false; + const apiServerUrl = process.env.NEXT_PUBLIC_API_SERVER_URL; + if (!apiServerUrl) return; + setSelectedEvent(null); + (async () => { + try { + const res = await fetch( + `${apiServerUrl}/api/messages/hackathon/${selectedEventId}`, + ); + if (!res.ok) return; + const data = await res.json(); + if (!cancelled) setSelectedEvent(data); + } catch (e) { + if (!cancelled) console.warn("Failed to load event for admin:", e); + } + })(); + return () => { + cancelled = true; + }; + }, [selectedEventId]); + + const depositEnabled = Boolean( + selectedEvent?.constraints?.hacker_deposit?.enabled, + ); + + // Eligible for bulk refund: paid + disposition=refund. Donate-disposition + // is excluded (override is a per-row decision); refund_failed is excluded + // (those likely need human triage). + const eligibleForBulkRefund = useMemo(() => { + if (!depositEnabled) return []; + return (volunteers.hackers || []).filter((h) => { + const state = getDepositState(h); + return state.kind === "paid" && h.deposit_disposition === "refund"; + }); + }, [volunteers.hackers, depositEnabled]); + + const handleDepositClick = useCallback((volunteer) => { + setDepositDialogVolunteer(volunteer); + }, []); + + const handleDepositRefunded = useCallback(() => { + // Re-fetch hackers so the chip/state updates immediately. + dataLoadedRef.current = false; + fetchVolunteers(); + setSnackbar({ + open: true, + message: "Deposit refunded successfully", + severity: "success", + }); + }, [fetchVolunteers]); + + const handleBulkRefundComplete = useCallback(() => { + dataLoadedRef.current = false; + fetchVolunteers(); + }, [fetchVolunteers]); + + const handleRequestSort = useCallback( + (property) => { + const isAsc = orderBy === property && order === "asc"; + setOrder(isAsc ? "desc" : "asc"); + setOrderBy(property); + }, + [order, orderBy] + ); + + const handleTabChange = useCallback((event, newValue) => { + setTabValue(newValue); + // Clear scroll position when switching tabs for fresh start + savedScrollPositionRef.current = 0; + sessionStorage.removeItem('volunteer-admin-scroll'); + }, []); + + const handleEventChange = useCallback((eventId) => { + setSelectedEventId(eventId); + // Reset data loaded flag when event changes to ensure fresh data + dataLoadedRef.current = false; + }, []); + + const getCurrentEventName = useCallback(() => { + if (!selectedEventId || !Array.isArray(hackathons)) return 'Volunteer Management'; + const currentEvent = hackathons.find(h => h?.event_id === selectedEventId); + return currentEvent ? `${currentEvent.event_id} - ${currentEvent.start_date}` : 'Volunteer Management'; + }, [selectedEventId, hackathons]); + + const generateShareLink = useCallback(() => { + if (!selectedEventId || typeof window === 'undefined') return ''; + const baseUrl = window.location.origin; + return `${baseUrl}/admin/volunteer?event_id=${selectedEventId}&tab=${tabValue}`; + }, [selectedEventId, tabValue]); + + const handleShareLink = useCallback(() => { + const shareUrl = generateShareLink(); + if (navigator.share) { + navigator.share({ + title: `Volunteer Management - ${getCurrentEventName()}`, + url: shareUrl, + }).catch(console.error); + } else { + navigator.clipboard.writeText(shareUrl).then(() => { + setShareSnackbar({ open: true, message: 'Link copied to clipboard!' }); + }).catch(() => { + setShareSnackbar({ open: true, message: 'Failed to copy link' }); + }); + } + }, [generateShareLink, getCurrentEventName]); + + const getPageTitle = useCallback(() => { + const eventName = getCurrentEventName(); + const tabNames = ['Mentors', 'Judges', 'Volunteers', 'Hackers', 'Sponsors']; + const currentTab = tabNames[tabValue] || 'Volunteer'; + return `${currentTab} - ${eventName}`; + }, [getCurrentEventName, tabValue]); + + const handleEditChange = useCallback((field, value) => { + setEditingVolunteer((prev) => ({ ...prev, [field]: value })); + }, []); + + // Filter state management helpers + const updateFilterState = useCallback((field, value) => { + const currentType = getCurrentVolunteerType(tabValue); + if (!currentType) return; + + setFilterStates(prev => ({ + ...prev, + [currentType]: { + ...prev[currentType], + [field]: value + } + })); + }, [tabValue]); + + const getCurrentFilterState = useCallback(() => { + const currentType = getCurrentVolunteerType(tabValue); + return filterStates[currentType] || { + filter: '', + statusFilter: 'all', + inPersonFilter: 'all', + checkedInFilter: 'all', + sortBy: 'timestamp', + sortOrder: 'desc', + showBatchActions: false + }; + }, [tabValue, filterStates]); + + // Get searchable fields for each volunteer type + const getSearchableFields = useCallback((volunteer, type) => { + // Helper function to safely get field value + const getFieldValue = (field) => { + if (field === null || field === undefined) return ''; + if (typeof field === 'string') return field.trim(); + if (typeof field === 'boolean') return field.toString(); + if (Array.isArray(field)) return field.join(' '); + return field.toString(); + }; + + const commonFields = [ + volunteer.name, + volunteer.email, + volunteer.pronouns, + volunteer.company, + volunteer.companyName, + volunteer.linkedinProfile + ]; + + switch (type) { + case "mentors": + return [ + ...commonFields, + volunteer.expertise, + volunteer.country, + volunteer.state, + volunteer.title, + volunteer.bio, + volunteer.availability, + volunteer.mentorshipAreas, + volunteer.previousMentoring + ].map(getFieldValue); + + case "judges": + return [ + ...commonFields, + volunteer.title, + volunteer.background, + volunteer.backgroundAreas, + volunteer.biography, + volunteer.shortBio, + volunteer.shortBiography, + volunteer.whyJudge, + volunteer.additionalInfo, + volunteer.availability, + volunteer.canAttendJudging, + volunteer.participationCount, + volunteer.otherBackground, + volunteer.country, + volunteer.state, + volunteer.inPerson + ].map(getFieldValue); + + case "volunteers": + return [ + ...commonFields, + volunteer.title, + volunteer.volunteerType, + volunteer.volunteerRole, + volunteer.experienceLevel, + volunteer.availability, + volunteer.availableDays, + volunteer.skills, + volunteer.previousVolunteering, + volunteer.motivation, + volunteer.bio, + volunteer.shortBio, + volunteer.socialCauses, + volunteer.otherSocialCause, + volunteer.linkedin, + volunteer.linkedinProfile, + volunteer.portfolio, + ...(volunteer.artifacts || []).map(artifact => [ + artifact.label, + artifact.comment + ]).flat() + ].map(getFieldValue); + + case "hackers": + return [ + ...commonFields, + volunteer.participantType, + volunteer.experienceLevel, + volunteer.teamStatus, + volunteer.primaryRoles, + volunteer.skills, + volunteer.schoolOrganization, + volunteer.bio, + volunteer.motivation, + volunteer.socialCauses, + volunteer.github, + volunteer.portfolio + ].map(getFieldValue); + + case "sponsors": + return [ + ...commonFields, + volunteer.sponsorshipTypes, + volunteer.sponsorshipTier, + volunteer.sponsorshipDetails, + volunteer.sponsorshipLevel, + volunteer.title, + volunteer.contactName, + volunteer.industry, + volunteer.employeeCount, + volunteer.website, + volunteer.specialRequests, + volunteer.bio, + volunteer.volunteerType, + volunteer.volunteerCount, + volunteer.volunteerHours, + volunteer.phoneNumber, + volunteer.preferredContact, + volunteer.useLogo, + volunteer.howHeard, + volunteer.otherInvolvement + ].map(getFieldValue); + + default: + return commonFields.map(getFieldValue); + } + }, []); + + const handleEditVolunteer = useCallback( + (volunteer) => { + setEditingVolunteer({ + ...volunteer, + type: getCurrentVolunteerType(tabValue), + }); + setIsAdding(false); + setEditDialogOpen(true); + }, + [tabValue] + ); + + const handleMessageVolunteer = useCallback((volunteer) => { + setSelectedVolunteerForMessage({ + ...volunteer, + type: getCurrentVolunteerType(tabValue), + }); + setCommunicationDialogOpen(true); + }, [tabValue]); + + const handleSlackInvite = useCallback((volunteers, type) => { + setVolunteersForSlackInvite(volunteers); + setVolunteerTypeForSlackInvite(type); + setSlackInviteDialogOpen(true); + }, []); + + const handleSlackInviteComplete = useCallback((summary) => { + setSnackbar({ + open: true, + message: `Slack invitations completed: ${summary.successful} successful, ${summary.failed} failed`, + severity: summary.failed > 0 ? "warning" : "success", + }); + setSlackInviteDialogOpen(false); + setVolunteersForSlackInvite([]); + setVolunteerTypeForSlackInvite(''); + }, []); + + const handleBatchEmail = useCallback((volunteers, type, isSelected = true) => { + setVolunteersForBatchEmail(volunteers); + setVolunteerTypeForBatchEmail(type); + setIsSelectedUsersForEmail(isSelected); + setBatchEmailDialogOpen(true); + }, []); + + const handleBatchEmailComplete = useCallback((summary) => { + const messageType = isSelectedUsersForEmail ? 'Batch emails' : 'Rejection emails'; + setSnackbar({ + open: true, + message: `${messageType} completed: ${summary.successful} successful, ${summary.failed} failed`, + severity: summary.failed > 0 ? "warning" : "success", + }); + setBatchEmailDialogOpen(false); + setVolunteersForBatchEmail([]); + setVolunteerTypeForBatchEmail(''); + setIsSelectedUsersForEmail(true); + }, [isSelectedUsersForEmail]); + + const handleBulkCertificate = useCallback((volunteers, type) => { + setVolunteersForBulkCertificate(volunteers); + setVolunteerTypeForBulkCertificate(type); + setBulkCertificateDialogOpen(true); + }, []); + + const handleBulkCertificateComplete = useCallback((summary) => { + setSnackbar({ + open: true, + message: `Bulk certificates completed: ${summary.successful} successful, ${summary.failed} failed`, + severity: summary.failed > 0 ? "warning" : "success", + }); + setBulkCertificateDialogOpen(false); + setVolunteersForBulkCertificate([]); + setVolunteerTypeForBulkCertificate(''); + // Optionally refresh volunteers data to reflect updated heart counts + dataLoadedRef.current = false; + fetchVolunteers(); + }, [fetchVolunteers]); + + const handleAddSingleVolunteer = useCallback(() => { + setEditingVolunteer({ + type: getCurrentVolunteerType(tabValue), + name: "", + photoUrl: "", + linkedinProfile: "", + isInPerson: false, + isSelected: false, + pronouns: "", + slack_user_id: "", + }); + setIsAdding(true); + setEditDialogOpen(true); + }, [tabValue]); + + const handleSaveEdit = useCallback(async () => { + if (!selectedEventId) return; + + setLoading(true); + try { + const url = `${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/messages/hackathon/${selectedEventId}/${getCurrentVolunteerTypeSingular(tabValue)}`; + const method = isAdding ? "POST" : "PATCH"; + + const volunteerData = { + ...editingVolunteer, + timestamp: isAdding + ? new Date().toISOString() + : editingVolunteer.timestamp, + }; + + const response = await fetch(url, { + method: method, + headers: { + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + "X-Org-Id": orgId, + }, + body: JSON.stringify(volunteerData), + }); + + if (response.ok) { + setSnackbar({ + open: true, + message: isAdding + ? "Volunteer added successfully" + : "Volunteer updated successfully", + severity: "success", + }); + // Reset data flag to force refresh after editing + dataLoadedRef.current = false; + fetchVolunteers(); + } else { + throw new Error( + isAdding ? "Failed to add volunteer" : "Failed to update volunteer" + ); + } + } catch (error) { + setSnackbar({ + open: true, + message: `Failed to ${isAdding ? "add" : "update"} volunteer. Please try again.`, + severity: "error", + }); + } finally { + setLoading(false); + setEditDialogOpen(false); + } + }, [ + editingVolunteer, + isAdding, + accessToken, + orgId, + fetchVolunteers, + tabValue, + selectedEventId, + ]); + + // Application review functions + const handleApproveApplication = useCallback(async (application) => { + if (!selectedEventId) return; + + setLoading(true); + try { + const currentType = getCurrentVolunteerTypeSingular(tabValue); + const url = `${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/messages/hackathon/${selectedEventId}/${currentType}`; + + const updatedApplication = { + ...application, + isSelected: true + }; + + const response = await fetch(url, { + method: "PATCH", + headers: { + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + "X-Org-Id": orgId, + }, + body: JSON.stringify(updatedApplication), + }); + + if (response.ok) { + setSnackbar({ + open: true, + message: "Application approved successfully", + severity: "success", + }); + fetchVolunteers(); + } else { + throw new Error("Failed to approve application"); + } + } catch (error) { + console.error('Error approving application:', error); + setSnackbar({ + open: true, + message: "Failed to approve application. Please try again.", + severity: "error", + }); + } finally { + setLoading(false); + } + }, [accessToken, orgId, fetchVolunteers, tabValue, selectedEventId]); + + const handleRejectApplication = useCallback(async (application) => { + if (!selectedEventId) return; + + setLoading(true); + try { + const currentType = getCurrentVolunteerTypeSingular(tabValue); + const url = `${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/messages/hackathon/${selectedEventId}/${currentType}`; + + const updatedApplication = { + ...application, + isSelected: false + }; + + const response = await fetch(url, { + method: "PATCH", + headers: { + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + "X-Org-Id": orgId, + }, + body: JSON.stringify(updatedApplication), + }); + + if (response.ok) { + setSnackbar({ + open: true, + message: "Application rejected successfully", + severity: "success", + }); + fetchVolunteers(); + } else { + throw new Error("Failed to reject application"); + } + } catch (error) { + console.error('Error rejecting application:', error); + setSnackbar({ + open: true, + message: "Failed to reject application. Please try again.", + severity: "error", + }); + } finally { + setLoading(false); + } + }, [accessToken, orgId, fetchVolunteers, tabValue, selectedEventId]); + + const handleBatchApproveApplications = useCallback(async (applications) => { + if (!selectedEventId || applications.length === 0) return; + + setLoading(true); + try { + const currentType = getCurrentVolunteerTypeSingular(tabValue); + const url = `${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/messages/hackathon/${selectedEventId}/${currentType}`; + + // Process applications in batches + await Promise.all(applications.map(async (application) => { + const updatedApplication = { + ...application, + isSelected: true + }; + + const response = await fetch(url, { + method: "PATCH", + headers: { + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + "X-Org-Id": orgId, + }, + body: JSON.stringify(updatedApplication), + }); + + if (!response.ok) { + throw new Error(`Failed to approve application for ${application.name || 'Unknown'}`); + } + })); + + setSnackbar({ + open: true, + message: `Successfully approved ${applications.length} applications`, + severity: "success", + }); + fetchVolunteers(); + } catch (error) { + console.error('Error batch approving applications:', error); + setSnackbar({ + open: true, + message: "Failed to approve some applications. Please try again.", + severity: "error", + }); + } finally { + setLoading(false); + } + }, [accessToken, orgId, fetchVolunteers, tabValue, selectedEventId]); + + const handleBatchRejectApplications = useCallback(async (applications) => { + if (!selectedEventId || applications.length === 0) return; + + setLoading(true); + try { + const currentType = getCurrentVolunteerTypeSingular(tabValue); + const url = `${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/messages/hackathon/${selectedEventId}/${currentType}`; + + // Process applications in batches + await Promise.all(applications.map(async (application) => { + const updatedApplication = { + ...application, + isSelected: false + }; + + const response = await fetch(url, { + method: "PATCH", + headers: { + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + "X-Org-Id": orgId, + }, + body: JSON.stringify(updatedApplication), + }); + + if (!response.ok) { + throw new Error(`Failed to reject application for ${application.name || 'Unknown'}`); + } + })); + + setSnackbar({ + open: true, + message: `Successfully rejected ${applications.length} applications`, + severity: "success", + }); + fetchVolunteers(); + } catch (error) { + console.error('Error batch rejecting applications:', error); + setSnackbar({ + open: true, + message: "Failed to reject some applications. Please try again.", + severity: "error", + }); + } finally { + setLoading(false); + } + }, [accessToken, orgId, fetchVolunteers, tabValue, selectedEventId]); + + // Application edit functions + const handleEditApplication = useCallback((application) => { + setEditingApplication(application); + setApplicationEditDialogOpen(true); + }, []); + + const handleSaveApplicationEdit = useCallback(async (updatedApplication) => { + if (!selectedEventId) return; + + setLoading(true); + try { + const currentType = getCurrentVolunteerTypeSingular(tabValue); + const url = `${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/messages/hackathon/${selectedEventId}/${currentType}`; + + const response = await fetch(url, { + method: "PATCH", + headers: { + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + "X-Org-Id": orgId, + }, + body: JSON.stringify(updatedApplication), + }); + + if (response.ok) { + setSnackbar({ + open: true, + message: "Application updated successfully", + severity: "success", + }); + fetchVolunteers(); + setApplicationEditDialogOpen(false); + setEditingApplication(null); + } else { + throw new Error("Failed to update application"); + } + } catch (error) { + console.error('Error updating application:', error); + setSnackbar({ + open: true, + message: "Failed to update application. Please try again.", + severity: "error", + }); + } finally { + setLoading(false); + } + }, [accessToken, orgId, fetchVolunteers, tabValue, selectedEventId]); + + const sortedVolunteers = useMemo(() => { + const currentVolunteers = volunteers?.[getCurrentVolunteerType(tabValue)] || []; + + if (!Array.isArray(currentVolunteers)) { + return []; + } + + // Count availability slots by splitting on day-name boundaries + const countSlots = (availStr) => { + if (!availStr) return 0; + return availStr.split(/,\s*(?=(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday))/).filter(s => s.trim()).length; + }; + + // Extract numeric year from participationCount (e.g., "This is my third year" -> 3) + const parseParticipationYear = (val) => { + if (!val) return 0; + const wordMap = { first: 1, second: 2, third: 3, fourth: 4, fifth: 5 }; + const match = val.match(/(first|second|third|fourth|fifth|\d+)/i); + if (!match) return 0; + return wordMap[match[1].toLowerCase()] || parseInt(match[1], 10) || 0; + }; + + return currentVolunteers + .filter(volunteer => volunteer != null) // Remove null/undefined entries + .sort((a, b) => { + let valueA, valueB; + + if (orderBy === "availability") { + valueA = countSlots(a?.availability); + valueB = countSlots(b?.availability); + } else if (orderBy === "participationCount") { + valueA = parseParticipationYear(a?.participationCount); + valueB = parseParticipationYear(b?.participationCount); + } else { + valueA = (a?.[orderBy] || "").toString().toLowerCase(); + valueB = (b?.[orderBy] || "").toString().toLowerCase(); + } + + if (valueA < valueB) { + return order === "asc" ? -1 : 1; + } + if (valueA > valueB) { + return order === "asc" ? 1 : -1; + } + return 0; + }) + .filter((volunteer) => { + if (!volunteer) return false; + if (!filter) return true; + + const searchValue = filter.toLowerCase(); + const currentType = getCurrentVolunteerType(tabValue); + const searchableFields = getSearchableFields(volunteer, currentType); + + return searchableFields.some(field => { + // Field is already processed by getFieldValue in getSearchableFields + if (!field || field === '') return false; + return field.toLowerCase().includes(searchValue); + }); + }); + }, [volunteers, getSearchableFields, orderBy, order, filter, tabValue]); + + // Add error boundary-like behavior + if (!router) { + return
Router not available
; + } + + const Outer = embedded ? React.Fragment : AdminPage; + const outerProps = embedded + ? {} + : { + title: "Volunteer Management", + snackbar, + onSnackbarClose: () => setSnackbar({ ...snackbar, open: false }), + isAdmin, + }; + + return ( + <> + {!embedded && ( + + {getPageTitle()} + + )} + + + {isMobile ? ( + // Mobile layout - stacked vertically + + {/* Primary Actions Row */} + + + + + + {/* Event Selector - Full Width */} + {!embedded && ( + + + Hackathon Event + + + + )} + + {/* View Mode & Share Row */} + + { + if (newViewMode !== null) { + setViewMode(newViewMode); + } + }} + aria-label="view mode" + size="small" + sx={{ flex: '1 1 auto' }} + > + + {isSmallMobile ? 'Table' : 'Table View'} + + + {isSmallMobile ? 'Review' : 'Review Mode'} + + + + + + + + + + {/* Search Field - Only show in table mode */} + {viewMode === "table" && ( + { + const currentType = getCurrentVolunteerType(tabValue); + if (isSmallMobile) { + return `Search ${currentType}`; + } + switch (currentType) { + case "judges": + return "Search all fields (name, email, company, bio, background, etc.)"; + case "mentors": + return "Search all fields (name, email, company, expertise, etc.)"; + case "hackers": + return "Search all fields (name, email, skills, experience, etc.)"; + case "volunteers": + return "Search all fields (name, email, role, skills, etc.)"; + case "sponsors": + return "Search all fields (company, contact, tier, industry, etc.)"; + default: + return "Search all available fields"; + } + })()} + value={filter} + onChange={(e) => setFilter(e.target.value)} + variant="outlined" + /> + )} + + ) : ( + // Desktop layout - horizontal + + + + + + + + + { + if (newViewMode !== null) { + setViewMode(newViewMode); + } + }} + aria-label="view mode" + size="small" + > + + Table View + + + Review Mode + + + + {!embedded && ( + + + + Hackathon Event + + + + + )} + + + + + + + + {viewMode === "table" && ( + + { + const currentType = getCurrentVolunteerType(tabValue); + switch (currentType) { + case "judges": + return "Search all fields (name, email, company, bio, background, etc.)"; + case "mentors": + return "Search all fields (name, email, company, expertise, etc.)"; + case "hackers": + return "Search all fields (name, email, skills, experience, etc.)"; + case "volunteers": + return "Search all fields (name, email, role, skills, etc.)"; + case "sponsors": + return "Search all fields (company, contact, tier, industry, etc.)"; + default: + return "Search all available fields"; + } + })()} + value={filter} + onChange={(e) => setFilter(e.target.value)} + /> + + )} + + )} + + + + + {isMobile ? 'Mentors' : 'Mentors'} + + } + /> + + {isMobile ? 'Judges' : 'Judges'} + + } + /> + + {isMobile ? 'Vol.' : 'Volunteers'} + + } + /> + + {isMobile ? 'Hackers' : 'Hackers'} + + } + /> + + {isMobile ? 'Sponsors' : 'Sponsors'} + + } + /> + + + {!selectedEventId ? ( + + Please select a hackathon event + + ) : loading ? ( + + + + ) : ( + + {viewMode === "table" ? ( + <> + {tabValue === 3 && depositEnabled && ( + + + + Eligible = paid + disposition=refund. Donated and + refund-failed rows excluded. + + + )} + updateFilterState('checkedInFilter', value)} + accessToken={accessToken} + orgId={orgId} + depositEnabled={depositEnabled} + onDepositClick={handleDepositClick} + /> + {sortedVolunteers.length === 0 && ( + + + No {getCurrentVolunteerType(tabValue)} found for this hackathon + event. + + + )} + + ) : ( + updateFilterState('filter', value)} + onStatusFilterChange={(value) => updateFilterState('statusFilter', value)} + onInPersonFilterChange={(value) => updateFilterState('inPersonFilter', value)} + onCheckedInFilterChange={(value) => updateFilterState('checkedInFilter', value)} + onSortByChange={(value) => updateFilterState('sortBy', value)} + onSortOrderChange={(value) => updateFilterState('sortOrder', value)} + onShowBatchActionsChange={(value) => updateFilterState('showBatchActions', value)} + /> + )} + + )} + + setEditDialogOpen(false)} + volunteer={editingVolunteer} + onSave={handleSaveEdit} + onChange={handleEditChange} + isAdding={isAdding} + /> + + { + setApplicationEditDialogOpen(false); + setEditingApplication(null); + }} + application={editingApplication} + applicationType={getCurrentVolunteerTypeSingular(tabValue)} + onSave={handleSaveApplicationEdit} + isLoading={loading} + /> + + {/* Volunteer Communication Dialog */} + {selectedVolunteerForMessage && ( + { + setCommunicationDialogOpen(false); + setSelectedVolunteerForMessage(null); + }} + onMessageSent={() => { + setCommunicationDialogOpen(false); + setSelectedVolunteerForMessage(null); + fetchVolunteers(); // Refresh data + }} + /> + )} + + {/* Slack Invite Dialog */} + { + setSlackInviteDialogOpen(false); + setVolunteersForSlackInvite([]); + setVolunteerTypeForSlackInvite(''); + }} + volunteers={volunteersForSlackInvite} + volunteerType={volunteerTypeForSlackInvite} + accessToken={accessToken} + orgId={orgId} + onComplete={handleSlackInviteComplete} + /> + + {/* Batch Email Dialog */} + { + setBatchEmailDialogOpen(false); + setVolunteersForBatchEmail([]); + setVolunteerTypeForBatchEmail(''); + setIsSelectedUsersForEmail(true); + }} + volunteers={volunteersForBatchEmail} + volunteerType={volunteerTypeForBatchEmail} + accessToken={accessToken} + orgId={orgId} + eventId={selectedEventId} + onComplete={handleBatchEmailComplete} + isSelectedUsers={isSelectedUsersForEmail} + /> + + {/* Bulk Certificate Dialog */} + { + setBulkCertificateDialogOpen(false); + setVolunteersForBulkCertificate([]); + setVolunteerTypeForBulkCertificate(''); + }} + volunteers={volunteersForBulkCertificate} + volunteerType={volunteerTypeForBulkCertificate} + accessToken={accessToken} + orgId={orgId} + onComplete={handleBulkCertificateComplete} + /> + + setDepositDialogVolunteer(null)} + volunteer={depositDialogVolunteer} + apiServerUrl={process.env.NEXT_PUBLIC_API_SERVER_URL} + accessToken={accessToken} + orgId={orgId} + onRefunded={handleDepositRefunded} + /> + + setBulkRefundDialogOpen(false)} + eligibleHackers={eligibleForBulkRefund} + eventId={selectedEventId} + apiServerUrl={process.env.NEXT_PUBLIC_API_SERVER_URL} + accessToken={accessToken} + orgId={orgId} + onComplete={handleBulkRefundComplete} + /> + + {/* Share Link Snackbar */} + setShareSnackbar({ open: false, message: '' })} + message={shareSnackbar.message} + /> + + + ); +}; + +const AdminVolunteerPage = withRequiredAuthInfo(VolunteerWorkbench); + +export default AdminVolunteerPage; +export { VolunteerWorkbench }; diff --git a/src/components/badge-list.js b/src/components/badge-list.js deleted file mode 100644 index f2f1d0c6..00000000 --- a/src/components/badge-list.js +++ /dev/null @@ -1,23 +0,0 @@ -import React from "react"; -import { Puff } from 'react-loading-icons' -import LinkedInButton from "./buttons/linkedin-button"; - -export default function BadgeList({badges}){ - if( badges == null ) - { - return (

Loading...

); - } - - return( -
-
- {badges.length > 0 ? : null} -
- { - badges.map(badge => { - return
Badge{badge.description}
; - }) - } -
- ) -}; diff --git a/src/components/buttons/linkedin-button.js b/src/components/buttons/linkedin-button.js deleted file mode 100644 index 45d906b1..00000000 --- a/src/components/buttons/linkedin-button.js +++ /dev/null @@ -1,13 +0,0 @@ -import React from "react"; -import Button from '@mui/material/Button'; - -export default function LinkedInButton(){ - /** - * Using the Signup feature requires you to enable - * the Auth0 New Universal Login Experience in your tenant. - * Learn more: https://auth0.com/docs/universal-login/new-experience - */ - return ( - - ); -}; diff --git a/src/components/design/refined.js b/src/components/design/refined.js new file mode 100644 index 00000000..ee02d83f --- /dev/null +++ b/src/components/design/refined.js @@ -0,0 +1,264 @@ +// Shared "civic editorial" design scope for the reimagined landing + projects +// pages. The brief was that the previous design felt "too busy" — so this +// module trades the multi-gradient, multi-chip, equal-weight layout for a calm, +// confident, magazine-like system: warm paper, a single deep-navy brand anchor, +// one terracotta accent used sparingly, a Fraunces / Hanken Grotesk type pairing, +// hairline rules, and a single orchestrated load animation. +// +// Everything is scoped under so it never leaks into the rest of +// the app (which keeps its global MUI theme). Fonts are injected per-page via +// inside next/head with preconnect + display=swap. + +import React from "react"; +import { styled } from "@mui/material/styles"; + +// --- Font s for the page . Preconnect keeps the swap cheap. --- +export const RefinedFonts = () => ( + <> + + + + +); + +// The scoped design system. All visual decisions live here as CSS variables + +// utility classNames so the page markup stays semantic and quiet. +export const RefinedRoot = styled("main")(({ theme }) => ({ + // --- Tokens --- + "--paper": "#FBFAF6", // warm off-white page + "--surface": "#FFFFFF", + "--surface-2": "#F4F1E9", // sunken warm band + "--ink": "#16181D", // near-black, warm + "--muted": "#5B6270", // slate for secondary text + "--faint": "#8A8F9A", + "--line": "#E7E1D4", // warm hairline + "--brand": "#1B3A6B", // deepened OHack navy + "--brand-ink": "#0E2547", // darker navy for hovers + "--accent": "#E2552E", // terracotta — the "social good" highlight + "--accent-soft": "#FBE9E2", + "--display": "'Fraunces', Georgia, 'Times New Roman', serif", + "--body": "'Hanken Grotesk', system-ui, -apple-system, sans-serif", + + position: "relative", + width: "100%", + minHeight: "100vh", + backgroundColor: "var(--paper)", + color: "var(--ink)", + fontFamily: "var(--body)", + fontSize: "17px", + lineHeight: 1.65, + WebkitFontSmoothing: "antialiased", + textRendering: "optimizeLegibility", + overflowX: "hidden", + + // Faint paper grain / vignette via layered radial gradients (atmosphere + // without noise files). Sits behind content, never intercepts clicks. + "&::before": { + content: '""', + position: "absolute", + inset: 0, + pointerEvents: "none", + zIndex: 0, + background: + "radial-gradient(120% 60% at 85% -10%, rgba(27,58,107,0.06), transparent 60%), radial-gradient(90% 50% at -10% 0%, rgba(226,85,46,0.05), transparent 55%)", + }, + "& > *": { position: "relative", zIndex: 1 }, + + // --- Layout helpers --- + "& .ohx-wrap": { + width: "100%", + maxWidth: 1120, + marginInline: "auto", + paddingInline: "clamp(20px, 5vw, 56px)", + }, + "& .ohx-narrow": { maxWidth: 760 }, + + // --- Type --- + "& .ohx-display": { + fontFamily: "var(--display)", + fontWeight: 500, + fontOpticalSizing: "auto", + lineHeight: 1.04, + letterSpacing: "-0.015em", + margin: 0, + }, + "& h1.ohx-display": { fontSize: "clamp(2.6rem, 6.2vw, 5rem)", fontWeight: 500 }, + "& h2.ohx-display": { fontSize: "clamp(1.9rem, 3.6vw, 2.9rem)" }, + "& h3.ohx-display": { fontSize: "clamp(1.3rem, 2.2vw, 1.7rem)", lineHeight: 1.15 }, + "& .ohx-italic": { fontStyle: "italic", color: "var(--accent)", fontWeight: 400 }, + + "& .ohx-eyebrow": { + fontFamily: "var(--body)", + textTransform: "uppercase", + letterSpacing: "0.22em", + fontSize: "0.72rem", + fontWeight: 600, + color: "var(--muted)", + margin: 0, + }, + "& .ohx-lead": { + fontSize: "clamp(1.05rem, 1.6vw, 1.3rem)", + lineHeight: 1.6, + color: "var(--muted)", + maxWidth: "46ch", + margin: 0, + }, + "& .ohx-muted": { color: "var(--muted)" }, + "& .ohx-faint": { color: "var(--faint)" }, + + // --- Hairline rule --- + "& .ohx-rule": { + height: 1, + border: 0, + background: "var(--line)", + width: "100%", + margin: 0, + }, + + // --- Buttons (squared, confident — no pills, no shadows) --- + "& .ohx-btn": { + display: "inline-flex", + alignItems: "center", + gap: "0.55em", + fontFamily: "var(--body)", + fontWeight: 600, + fontSize: "1rem", + lineHeight: 1, + padding: "0.95em 1.5em", + borderRadius: 4, + border: "1px solid transparent", + cursor: "pointer", + textDecoration: "none", + transition: "transform .18s ease, background-color .18s ease, color .18s ease, border-color .18s ease", + WebkitTapHighlightColor: "transparent", + }, + "& .ohx-btn:active": { transform: "translateY(1px)" }, + "& .ohx-btn--primary": { + background: "var(--brand)", + color: "#fff", + }, + "& .ohx-btn--primary:hover": { background: "#16315a" }, + "& .ohx-btn--ghost": { + background: "transparent", + color: "var(--ink)", + borderColor: "var(--line)", + }, + "& .ohx-btn--ghost:hover": { borderColor: "var(--ink)", background: "rgba(0,0,0,0.02)" }, + + // --- Inline link with an animated underline --- + "& .ohx-link": { + color: "var(--brand)", + fontWeight: 600, + textDecoration: "none", + display: "inline-flex", + alignItems: "center", + gap: "0.35em", + backgroundImage: "linear-gradient(var(--accent), var(--accent))", + backgroundSize: "0% 1.5px", + backgroundPosition: "0 100%", + backgroundRepeat: "no-repeat", + transition: "background-size .25s ease", + paddingBottom: 2, + }, + "& .ohx-link:hover": { backgroundSize: "100% 1.5px" }, + "& .ohx-link .ohx-arrow": { transition: "transform .25s ease" }, + "& .ohx-link:hover .ohx-arrow": { transform: "translateX(3px)" }, + + // --- Card (quiet surface, hairline border, lift on hover) --- + "& .ohx-card": { + background: "var(--surface)", + border: "1px solid var(--line)", + borderRadius: 8, + transition: "transform .22s ease, box-shadow .22s ease, border-color .22s ease", + }, + "& a.ohx-card:hover, & .ohx-card--hover:hover": { + transform: "translateY(-3px)", + boxShadow: "0 18px 40px -28px rgba(22,24,29,0.45)", + borderColor: "#d8d1c0", + }, + + // --- Tag (replaces the loud colored chips with one quiet treatment) --- + "& .ohx-tag": { + display: "inline-flex", + alignItems: "center", + fontSize: "0.78rem", + fontWeight: 500, + color: "var(--muted)", + background: "var(--surface-2)", + border: "1px solid var(--line)", + borderRadius: 999, + padding: "0.2em 0.75em", + lineHeight: 1.5, + }, + "& .ohx-tag--accent": { + color: "#b23a18", + background: "var(--accent-soft)", + borderColor: "#f3d3c7", + }, + + // --- Sponsor logos: desaturated until hovered (classic restrained move) --- + "& .ohx-sponsors img": { + filter: "grayscale(1)", + opacity: 0.55, + transition: "filter .25s ease, opacity .25s ease", + }, + "& .ohx-sponsors:hover img": { opacity: 0.78 }, + "& .ohx-sponsors img:hover": { filter: "none", opacity: 1 }, + + // --- One orchestrated load animation: staggered rise. --- + "@keyframes ohxRise": { + from: { opacity: 0, transform: "translateY(16px)" }, + to: { opacity: 1, transform: "none" }, + }, + "& .rise": { + opacity: 0, + animation: "ohxRise .8s cubic-bezier(.2,.7,.2,1) forwards", + }, + "@media (prefers-reduced-motion: reduce)": { + "& .rise": { animation: "none", opacity: 1 }, + "& .ohx-card, & .ohx-btn, & .ohx-link": { transition: "none" }, + }, + + // Selection color ties back to the accent. + "& ::selection": { background: "var(--accent)", color: "#fff" }, +})); + +// Small shared presentational atoms ---------------------------------------- + +export const Eyebrow = ({ children, style }) => ( +

+ {children} +

+); + +// A single understated metric used in the hero stat row. +export const Stat = ({ value, label }) => ( +
+ + {value} + + + {label} + +
+); + +// Right-pointing arrow used inside .ohx-link / buttons. +export const Arrow = () => ( + +); diff --git a/src/components/feedback-lite.js b/src/components/feedback-lite.js deleted file mode 100644 index b8b772f4..00000000 --- a/src/components/feedback-lite.js +++ /dev/null @@ -1,362 +0,0 @@ -import React from "react"; - -import { useState } from 'react' - -import Rating from '@mui/material/Rating'; -import FavoriteIcon from '@mui/icons-material/Favorite'; -import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder'; -import Typography from '@mui/material/Typography'; -import { styled } from '@mui/material/styles'; -import CopyAllIcon from '@mui/icons-material/CopyAll'; -import TextField from '@mui/material/TextField'; -import Link from 'next/link' -import Button from '@mui/material/Button'; -import Stack from '@mui/material/Stack'; -import Box from '@mui/material/Box'; - - -import { - Snackbar -} from '@mui/material' -import PrivacyToggle from './PrivacyToggle/PrivacyToggle'; -import usePrivacySettings from '../hooks/use-privacy-settings'; - - -const HEART_COLOR = '#ff6d75'; - -const StyledRating = styled(Rating)(({ theme }) => ({ - '& .MuiRating-iconFilled': { - color: HEART_COLOR, - }, - '& .MuiRating-iconHover': { - color: '#ff3d47', - }, - [theme.breakpoints.down('sm')]: { - '& .MuiRating-icon': { - fontSize: '1.2rem', - }, - }, -})); - -function RatingItem({ label, description, value, maxHearts }) { - return ( - - - - - {label} - - {description && ( - - {description} - - )} - - 0 ? HEART_COLOR : 'text.disabled', - whiteSpace: 'nowrap', - flexShrink: 0, - pt: 0.25, - }} - > - {value}/{maxHearts} - - - - `${v} Heart${v !== 1 ? "s" : ""}`} - precision={0.5} - max={maxHearts} - icon={} - emptyIcon={} - /> - - - ); -} - -// TODO: Is this part of a dead tree? -export default function FeedbackLite ( {feedback_url, history} ){ - const [open, setOpen] = useState(false); - const { privacySettings, togglePrivacySetting } = usePrivacySettings(); - - const whatPrivate = privacySettings.what === 'private'; - const howPrivate = privacySettings.how === 'private'; - - - const defaultHistory = { - "what": { - "productionalized_projects": 0.5, - "requirements_gathering": 0, - "documentation": 0, - "design_architecture": 0, - "code_quality": 0, - "unit_test_writing": 0, - "unit_test_coverage": 0, - "observability": 0, - "judge": 0, - "mentor": 0 - }, - "how": { - "standups_completed": 0, - "code_reliability": 0, - "customer_driven_innovation_and_design_thinking": 0, - "iterations_of_code_pushed_to_production": 0 - } - }; - if( history === "" || history === undefined ) - { - history = defaultHistory; - } - - const MAX_HEARTS = 10; - - /* - This is meant to be embedded on other pages which is why it's called "Lite" - The more correct term is likely Fragment - */ - - const handleClick = () => { - setOpen(true); - - if (feedback_url != null) { - navigator.clipboard.writeText(feedback_url); - } - } - - return ( - - - - - - setOpen(false)} - autoHideDuration={2000} - message="Copied link to clipboard" - /> - - - - - - - - - - - {/* What section */} - - - - What - - - - - What you've completed for nonprofits - - - - - - - - - - - You added monitoring capabilities to your software{" "} -
- like USE and RED. - - } - value={history.what.observability} - maxHearts={MAX_HEARTS} - /> - - - - - - {/* How section */} - - - - How - - - - - How you went about it - - - - - - - - Customer Driven Innovation (CDI) - {" "} - and{" "} - - Design Thinking - - } - description="You have consistent conversations with your customer to get feedback on what you're building." - value={history.how.customer_driven_innovation_and_design_thinking} - maxHearts={MAX_HEARTS} - /> - - - - - ); -}; diff --git a/src/components/profile-hackathon-list.js b/src/components/profile-hackathon-list.js deleted file mode 100644 index c5a7f361..00000000 --- a/src/components/profile-hackathon-list.js +++ /dev/null @@ -1,147 +0,0 @@ -import React from "react"; - -// https://www.npmjs.com/package/react-loading-icons -import { Puff } from 'react-loading-icons' - -import DownloadCertificateButton from "./buttons/download-certificate-button" -import { useTable } from 'react-table' -import NonProfitList from "./non-profit-list"; - - -export default function ProfileHackathonList ({hackathons}){ - const hackathonInfo = ({ hackathons }) => { - if (hackathons === null) { - // We want to show the loading icons while we are waiting for the data to load - // https://codeflarelimited.com/blog/react-js-show-and-hide-loading-animation-on-button-click/ - return(

Loading...

); - } - else if (hackathons.length === 0) { - return (

Nothing yet!

); - } - else{ - return null; - } - }; - - var data = [{ - col1: "", - col2: "", - col3: "", - col4: "", - col5: "" - }]; - if( hackathons != null ) - { - data = hackathons.map(hackathon => { - return { - col1: hackathon.start_date, - col2: hackathon.location, - col3: Link, - col4: , - col5: - }; - }); - } - - - - // I don't think this is needed: - // const data = React.useMemo( () => ahist, [] ) - - const columns = React.useMemo( - () => [ - { - Header: 'Start Date', - accessor: 'col1', // accessor is the "key" in the data - }, - { - Header: 'Location', - accessor: 'col2', // accessor is the "key" in the data - }, - { - Header: 'DevPost', - accessor: 'col3', - }, - { - Header: 'Certificate', - accessor: 'col4', - }, - { - Header: 'Nonprofit', - accessor: 'col5', - }, - ], - [] - ) - - const tableInstance = useTable({ columns, data }) - const { - getTableProps, - getTableBodyProps, - headerGroups, - rows, - prepareRow, - } = tableInstance - - - return( -
- {hackathonInfo({hackathons})} -
- - - {headerGroups.map(headerGroup => ( - - {headerGroup.headers.map(column => ( - - ))} - - ))} - - - {rows.map(row => { - prepareRow(row) - return ( - - {row.cells.map(cell => { - return ( - - ) - })} - - ) - })} - -
- {column.render('Header')} -
- {cell.render('Cell')} -
-
-
- ); - }; diff --git a/src/components/share/LinkedInShareButton.jsx b/src/components/share/LinkedInShareButton.jsx new file mode 100644 index 00000000..49ac02a7 --- /dev/null +++ b/src/components/share/LinkedInShareButton.jsx @@ -0,0 +1,79 @@ +import React, { useCallback } from "react"; +import PropTypes from "prop-types"; +import { Button, IconButton, Tooltip } from "@mui/material"; +import LinkedInIcon from "@mui/icons-material/LinkedIn"; + +import { + buildLinkedInShareUrl, + openShareWindow, +} from "../../utils/socialShare"; + +const SHARE_LABEL_DEFAULT = "Share to LinkedIn"; + +function LinkedInShareButton({ + url, + variant = "profile", + label = SHARE_LABEL_DEFAULT, + tooltip, + size = "medium", + fullWidth = false, + onShare, +}) { + const handleClick = useCallback( + (event) => { + event.preventDefault(); + const shareUrl = buildLinkedInShareUrl(url); + openShareWindow(shareUrl, "linkedin-share"); + if (typeof onShare === "function") { + onShare(shareUrl); + } + }, + [url, onShare] + ); + + if (variant === "section") { + return ( + + + + + + ); + } + + return ( + + ); +} + +LinkedInShareButton.propTypes = { + url: PropTypes.string, + variant: PropTypes.oneOf(["profile", "section"]), + label: PropTypes.string, + tooltip: PropTypes.string, + size: PropTypes.oneOf(["small", "medium", "large"]), + fullWidth: PropTypes.bool, + onShare: PropTypes.func, +}; + +export default LinkedInShareButton; diff --git a/src/data/sponsorData.js b/src/data/sponsorData.js index 9e500259..421f4725 100644 --- a/src/data/sponsorData.js +++ b/src/data/sponsorData.js @@ -62,6 +62,27 @@ export const sponsors = [ donations: 2000, website: "https://thebuzzeatery.com/", }, + { + name: "GoDaddy", + logo: "https://cdn.ohack.dev/ohack.dev/sponsors/godaddy.webp", + hours: 72, + donations: 0, + website: "https://godaddy.com", + }, + { + name: "Microchip", + logo: "https://cdn.ohack.dev/sponsors/1774211236001_mchp-logo-vertical-4c.png", + hours: 36, + donations: 0, + website: "https://www.microchip.com", + }, + { + name: "IQVIA", + logo: "https://cdn.ohack.dev/ohack.dev/sponsors/iqvia.webp", + hours: 22, + donations: 0, + website: "https://www.iqvia.com", + } ]; export const calculateSupport = (hours, donations) => hours * 100 + donations; diff --git a/src/hooks/use-email-templates.js b/src/hooks/use-email-templates.js new file mode 100644 index 00000000..bf76c1f3 --- /dev/null +++ b/src/hooks/use-email-templates.js @@ -0,0 +1,101 @@ +import { useState, useEffect, useCallback, useMemo } from "react"; +import { + MESSAGE_TEMPLATES, + groupTemplatesByCategory, +} from "../lib/messageTemplates"; + +// Module-level cache shared by every consumer (VolunteerCommunication mounts +// per volunteer row; BatchEmailDialog per tab) so opening dialogs doesn't +// refetch. refresh(true) busts it after admin edits. +const CACHE_TTL_MS = 60 * 1000; +let cache = { data: null, fetchedAt: 0, promise: null }; + +const fetchTemplateList = (accessToken, orgId) => + fetch(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/admin/templates`, { + headers: { + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + ...(orgId ? { "X-Org-Id": orgId } : {}), + }, + }).then((res) => { + if (!res.ok) throw new Error(`Failed to load templates (${res.status})`); + return res.json().then((data) => data.text || []); + }); + +/** + * Loads admin-managed email templates from the backend. + * + * Returns: + * - templates: grouped MESSAGE_TEMPLATES-shaped object. Falls back to the + * hardcoded MESSAGE_TEMPLATES while loading or on fetch failure, so the + * send-email dialogs always have something to offer. + * - rawTemplates: flat backend array (includes version/status/audit fields) + * or null until loaded — used by the template manager UI. + * - isFallback: true while templates is the hardcoded fallback. + */ +export default function useEmailTemplates({ + accessToken, + orgId, + enabled = true, +}) { + const [rawTemplates, setRawTemplates] = useState(cache.data); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const refresh = useCallback( + async (force = false) => { + if (!accessToken) return; + const isFresh = cache.data && Date.now() - cache.fetchedAt < CACHE_TTL_MS; + if (isFresh && !force) { + setRawTemplates(cache.data); + return; + } + setLoading(true); + setError(null); + try { + if (!cache.promise || force) { + cache.promise = fetchTemplateList(accessToken, orgId) + .then((list) => { + cache = { data: list, fetchedAt: Date.now(), promise: null }; + return list; + }) + .catch((err) => { + cache.promise = null; + throw err; + }); + } + const list = await cache.promise; + setRawTemplates(list); + } catch (err) { + console.error("Email template fetch failed:", err); + setError(err.message || "Failed to load templates"); + } finally { + setLoading(false); + } + }, + [accessToken, orgId], + ); + + useEffect(() => { + if (enabled && accessToken) { + refresh(); + } + }, [enabled, accessToken, refresh]); + + const grouped = useMemo( + () => + rawTemplates && rawTemplates.length > 0 + ? groupTemplatesByCategory(rawTemplates) + : null, + [rawTemplates], + ); + + return { + templates: grouped || MESSAGE_TEMPLATES, + rawTemplates, + isFallback: !grouped, + loading, + error, + refresh, + }; +} diff --git a/src/hooks/use-hackathon-events.js b/src/hooks/use-hackathon-events.js index 9c0ba98b..4ca50188 100644 --- a/src/hooks/use-hackathon-events.js +++ b/src/hooks/use-hackathon-events.js @@ -8,6 +8,7 @@ export default function useHackathonEvents( currentOnly ){ const { user } = useAuthInfo(); const { apiServerUrl } = useEnv(); const [hackathons, setHackathons] = useState([]); + const [loading, setLoading] = useState(true); const makeRequest = useCallback(async (options) => { @@ -81,6 +82,7 @@ export default function useHackathonEvents( currentOnly ){ useEffect(() => { const getHackathons = async () => { + setLoading(true); var config = { url: `${apiServerUrl}/api/messages/hackathons`, method: "GET", @@ -106,6 +108,7 @@ export default function useHackathonEvents( currentOnly ){ else { setHackathons([]) } + setLoading(false); }; getHackathons(); @@ -115,6 +118,7 @@ export default function useHackathonEvents( currentOnly ){ return { hackathons, + loading, handle_get_hackathon, handle_get_hackathon_id, handle_problem_statement_to_event_link_update diff --git a/src/hooks/use-live-team.js b/src/hooks/use-live-team.js new file mode 100644 index 00000000..1f0c3f97 --- /dev/null +++ b/src/hooks/use-live-team.js @@ -0,0 +1,61 @@ +import { useState, useEffect } from "react"; + +/** + * Keeps team + event state live after hydration. ISR (revalidate: 60) plus the + * backend's 10-min TTL on get_team would otherwise show stale users[] / + * checklist / mentor state for several minutes, so every team page refetches on + * the client once mounted. Mirrors the effect that lives on the team overview + * page so the mentor / completion sub-pages stay consistent. + * + * @param {string} eventId + * @param {string} teamId + * @param {object} initialTeam - SSR team data (from getStaticProps) + * @param {object} initialEvent - SSR event data + * @returns {{ team, setTeam, event, loading, error }} + */ +export default function useLiveTeam(eventId, teamId, initialTeam, initialEvent) { + const [team, setTeam] = useState(initialTeam || null); + const [event, setEvent] = useState(initialEvent || null); + const [loading, setLoading] = useState(!initialTeam); + const [error, setError] = useState(null); + + useEffect(() => { + if (!eventId || !teamId) return undefined; + const hadSsrData = !!(initialTeam && initialEvent); + let cancelled = false; + + const fetchData = async () => { + if (!hadSsrData) setLoading(true); + try { + const [teamRes, eventRes] = await Promise.all([ + fetch(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/messages/team/${teamId}`), + fetch(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/messages/hackathon/${eventId}`), + ]); + + if (!teamRes.ok) { + if (!hadSsrData && !cancelled) setError("Team not found"); + return; + } + + const teamJson = await teamRes.json(); + const eventJson = eventRes.ok ? await eventRes.json() : null; + if (cancelled) return; + setTeam(teamJson.team || teamJson); + if (eventJson) setEvent(eventJson); + } catch (err) { + console.error("Error fetching team data:", err); + if (!hadSsrData && !cancelled) setError("Failed to load team details"); + } finally { + if (!hadSsrData && !cancelled) setLoading(false); + } + }; + + fetchData(); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [eventId, teamId]); + + return { team, setTeam, event, loading, error }; +} diff --git a/src/hooks/use-planning-board.js b/src/hooks/use-planning-board.js new file mode 100644 index 00000000..e5cd6e09 --- /dev/null +++ b/src/hooks/use-planning-board.js @@ -0,0 +1,261 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useAuthInfo } from "@propelauth/react"; + +const API = process.env.NEXT_PUBLIC_API_SERVER_URL; +const HOT_INTERVAL = 6000; +const COLD_INTERVAL = 30000; +const HOT_WINDOW_MS = 60000; + +function buildHeaders(accessToken, orgId) { + const h = { "content-type": "application/json" }; + if (accessToken) h["authorization"] = `Bearer ${accessToken}`; + if (orgId) h["X-Org-Id"] = orgId; + return h; +} + +export function usePlanningBoard(eventId) { + const { accessToken, orgHelper, userClass, user } = useAuthInfo(); + const orgId = orgHelper?.getOrgs()?.[0]?.orgId; + + const [board, setBoard] = useState(null); // { lists, cards, labels, planning } + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const etagRef = useRef(null); + const lastMutationRef = useRef(null); + const pollRef = useRef(null); + + const isHot = useCallback(() => { + return lastMutationRef.current && Date.now() - lastMutationRef.current < HOT_WINDOW_MS; + }, []); + + const fetchBoard = useCallback(async () => { + if (!eventId) return; + try { + const headers = {}; + if (etagRef.current) headers["If-None-Match"] = etagRef.current; + + const res = await fetch(`${API}/api/planning/${eventId}`, { headers }); + if (res.status === 304) return; // no change + if (!res.ok) throw new Error(`HTTP ${res.status}`); + + const etag = res.headers.get("ETag"); + if (etag) etagRef.current = etag; + + const data = await res.json(); + setBoard(data); + setError(null); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }, [eventId]); + + // Initial fetch + adaptive polling + useEffect(() => { + if (!eventId) return; + fetchBoard(); + + const tick = () => { + if (document.visibilityState !== "visible") return; + fetchBoard(); + }; + + const schedule = () => { + clearInterval(pollRef.current); + pollRef.current = setInterval(tick, isHot() ? HOT_INTERVAL : COLD_INTERVAL); + }; + + schedule(); + const visibilityHandler = () => { + if (document.visibilityState === "visible") { + fetchBoard(); + schedule(); + } + }; + document.addEventListener("visibilitychange", visibilityHandler); + + return () => { + clearInterval(pollRef.current); + document.removeEventListener("visibilitychange", visibilityHandler); + }; + }, [eventId, fetchBoard, isHot]); + + // ---------- mutation helpers ---------- + + function markMutated() { + lastMutationRef.current = Date.now(); + clearInterval(pollRef.current); + pollRef.current = setInterval(fetchBoard, HOT_INTERVAL); + } + + async function mutate(method, path, body, optimisticFn) { + // Optimistic update + const prev = board; + if (optimisticFn) setBoard((b) => optimisticFn(b)); + + try { + const res = await fetch(`${API}/api/planning/${eventId}${path}`, { + method, + headers: buildHeaders(accessToken, orgId), + body: body ? JSON.stringify(body) : undefined, + }); + + if (res.status === 412) { + // Conflict — rollback and return the conflict payload + if (prev) setBoard(prev); + const data = await res.json(); + return { conflict: true, data }; + } + + if (!res.ok) { + if (prev) setBoard(prev); + const data = await res.json().catch(() => ({})); + throw new Error(data.error || `HTTP ${res.status}`); + } + + const result = await res.json(); + markMutated(); + await fetchBoard(); // sync server state + return { ok: true, data: result }; + } catch (err) { + if (prev) setBoard(prev); + return { ok: false, error: err.message }; + } + } + + // ---------- list operations ---------- + + const createList = (title) => mutate("POST", "/lists", { title }); + + const updateList = (listId, updates, currentUpdatedAt) => + mutate( + "PATCH", + `/lists/${listId}`, + updates, + null, + currentUpdatedAt ? { "If-Match": currentUpdatedAt } : undefined + ); + + // ---------- card operations ---------- + + const createCard = (listId, title, extra = {}) => + mutate("POST", "/cards", { list_id: listId, title, ...extra }); + + const updateCard = (cardId, updates, currentUpdatedAt) => { + const headers = currentUpdatedAt ? { "If-Match": currentUpdatedAt } : undefined; + return mutateWithHeaders("PATCH", `/cards/${cardId}`, updates, headers); + }; + + const archiveCard = (cardId) => mutate("DELETE", `/cards/${cardId}`); + + const moveCard = (cardId, listId, position, currentUpdatedAt) => + updateCard(cardId, { list_id: listId, position }, currentUpdatedAt); + + // ---------- comment operations ---------- + + const createComment = (cardId, body) => + mutate("POST", `/cards/${cardId}/comments`, { body }); + + const deleteComment = (commentId) => + mutate("DELETE", `/comments/${commentId}`); + + // ---------- label operations ---------- + + const createLabel = (name, color) => + mutate("POST", "/labels", { name, color }); + + const updateLabel = (labelId, updates) => + mutate("PATCH", `/labels/${labelId}`, updates); + + // ---------- admin operations ---------- + + const updateEditors = (add, remove) => + mutate("PATCH", "/editors", { add, remove }); + + const updateConfig = (config) => + mutate("PATCH", "/config", config); + + const seedTemplate = () => + mutate("POST", "/seed-template"); + + const slackNotify = () => + mutate("POST", "/slack/notify"); + + // ---------- helper for custom headers ---------- + + async function mutateWithHeaders(method, path, body, extraHeaders = {}) { + const prev = board; + try { + const res = await fetch(`${API}/api/planning/${eventId}${path}`, { + method, + headers: { ...buildHeaders(accessToken, orgId), ...extraHeaders }, + body: body ? JSON.stringify(body) : undefined, + }); + + if (res.status === 412) { + const data = await res.json(); + return { conflict: true, data }; + } + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || `HTTP ${res.status}`); + } + + const result = await res.json(); + markMutated(); + await fetchBoard(); + return { ok: true, data: result }; + } catch (err) { + if (prev) setBoard(prev); + return { ok: false, error: err.message }; + } + } + + // ---------- derived permissions ---------- + + const planning = board?.planning || {}; + const users = board?.users || {}; + const myId = user?.userId || null; + const canWrite = (() => { + if (!user) return false; + try { + // Match OHack pattern (see pages/admin/index.js): use userClass — + // orgHelper returns plain info objects without hasPermission(). + const org = userClass?.getOrgByName("Opportunity Hack Org"); + if (org?.hasPermission("volunteer.admin")) return true; + const editors = planning.editors || []; + return editors.includes(user.userId); + } catch { + return false; + } + })(); + const canComment = !!user; + + return { + board, + loading, + error, + canWrite, + canComment, + users, + myId, + refetch: fetchBoard, + // mutations + createList, + updateList, + createCard, + updateCard, + archiveCard, + moveCard, + createComment, + deleteComment, + createLabel, + updateLabel, + updateEditors, + updateConfig, + seedTemplate, + slackNotify, + }; +} diff --git a/src/hooks/use-praises-for-user.js b/src/hooks/use-praises-for-user.js new file mode 100644 index 00000000..501e0410 --- /dev/null +++ b/src/hooks/use-praises-for-user.js @@ -0,0 +1,66 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +const PAGE_SIZE = 20; + +/** + * Fetches praises received by the user identified by `dbUserId` from the public + * `/api/users/{id}/praises` endpoint. The hook is opt-in via `enabled` so the + * profile page can defer the network call until the user clicks "View all". + */ +export default function usePraisesForUser(dbUserId, { enabled = false } = {}) { + const [praises, setPraises] = useState([]); + const [total, setTotal] = useState(0); + const [offset, setOffset] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const initialFetchedRef = useRef(false); + + const fetchPage = useCallback( + async (nextOffset = 0) => { + if (!dbUserId) return; + setIsLoading(true); + setError(null); + try { + const url = `${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/users/${encodeURIComponent( + dbUserId + )}/praises?limit=${PAGE_SIZE}&offset=${nextOffset}`; + const res = await fetch(url, { headers: { "Content-Type": "application/json" } }); + if (res.status === 403) { + setPraises([]); + setTotal(0); + return; + } + if (!res.ok) { + throw new Error(`Failed to load praises (${res.status})`); + } + const json = await res.json(); + setTotal(json.total || 0); + setPraises((prev) => + nextOffset === 0 ? json.praises || [] : prev.concat(json.praises || []) + ); + setOffset(nextOffset + (json.praises?.length || 0)); + } catch (e) { + setError(e.message || "Failed to load praises"); + } finally { + setIsLoading(false); + } + }, + [dbUserId] + ); + + useEffect(() => { + if (!enabled || !dbUserId || initialFetchedRef.current) return; + initialFetchedRef.current = true; + fetchPage(0); + }, [enabled, dbUserId, fetchPage]); + + const loadMore = useCallback(() => { + if (isLoading) return; + if (praises.length >= total) return; + fetchPage(offset); + }, [isLoading, praises.length, total, offset, fetchPage]); + + const hasMore = praises.length < total; + + return { praises, total, hasMore, isLoading, error, loadMore }; +} diff --git a/src/hooks/use-privacy-settings.js b/src/hooks/use-privacy-settings.js index 423e22a3..8ffddefe 100644 --- a/src/hooks/use-privacy-settings.js +++ b/src/hooks/use-privacy-settings.js @@ -14,7 +14,8 @@ const DEFAULT_PRIVACY_SETTINGS = { feedback: "private", what: "private", how: "private", - hackathon_history: "private" + hackathon_history: "private", + praises: "public" }; export default function usePrivacySettings() { diff --git a/src/hooks/use-profile-api.js b/src/hooks/use-profile-api.js index 22596489..94554e85 100644 --- a/src/hooks/use-profile-api.js +++ b/src/hooks/use-profile-api.js @@ -172,7 +172,7 @@ export default function useProfileApi(){ company: data.text.company, github: data.text.github, history: data.text.history, - profile_url: window.location.href + "/" + data.text.id, // /profile/ + profile_url: `/profile/${data.text.id}`, // /profile/ linkedin_url: data.text.linkedin_url, instagram_url: data.text.instagram_url, propel_id: data.text.propel_id, diff --git a/src/hooks/use-public-profile.js b/src/hooks/use-public-profile.js index c0e27653..839ceffd 100644 --- a/src/hooks/use-public-profile.js +++ b/src/hooks/use-public-profile.js @@ -8,6 +8,8 @@ export default function usePublicProfile(userId) { const [profile, setProfile] = useState(null); const [badges, setBadges] = useState(null); const [hackathons, setHackathons] = useState(null); + const [praisesRecent, setPraisesRecent] = useState([]); + const [praisesCount, setPraisesCount] = useState(0); const [feedbackUrl, setFeedbackUrl] = useState(""); const [privacySettings, setPrivacySettings] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -73,6 +75,7 @@ export default function usePublicProfile(userId) { what: "private", how: "private", hackathon_history: "private", + praises: "private", }); } } catch (privacyError) { @@ -101,7 +104,9 @@ export default function usePublicProfile(userId) { // For now, we'll use the existing profile data structure // In the future, we might want separate endpoints for badges and hackathons setBadges(profileData.badges || []); - setHackathons(profileData.hackathons || []); + setHackathons(profileData.hackathon_history || profileData.hackathons || []); + setPraisesRecent(profileData.praises_recent || []); + setPraisesCount(profileData.praises_count || 0); } catch (err) { console.error("Error fetching public profile:", err); setError(err.message); @@ -120,6 +125,8 @@ export default function usePublicProfile(userId) { profile, badges, hackathons, + praisesRecent, + praisesCount, feedbackUrl, privacySettings, isLoading, diff --git a/src/hooks/use-team-membership.js b/src/hooks/use-team-membership.js new file mode 100644 index 00000000..34eb5f37 --- /dev/null +++ b/src/hooks/use-team-membership.js @@ -0,0 +1,42 @@ +import { useState, useEffect } from "react"; +import { useAuthInfo } from "@propelauth/react"; + +/** + * Checks whether the logged-in user is a member of a specific team for an event. + * Membership must come from the server because the public team payload omits `propel_id` (PII). + */ +export default function useTeamMembership(eventId, teamId) { + const { accessToken, isLoggedIn } = useAuthInfo(); + const [isOnTeam, setIsOnTeam] = useState(false); + const [membershipChecked, setMembershipChecked] = useState(false); + + useEffect(() => { + let cancelled = false; + if (!isLoggedIn || !accessToken || !eventId || !teamId) { + setIsOnTeam(false); + setMembershipChecked(!!eventId); + return undefined; + } + setMembershipChecked(false); + fetch(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/team/${eventId}/me`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + .then((r) => (r.ok ? r.json() : { teams: [] })) + .then((data) => { + if (cancelled) return; + const myTeams = Array.isArray(data?.teams) ? data.teams : []; + setIsOnTeam(myTeams.some((t) => t?.id === teamId)); + setMembershipChecked(true); + }) + .catch(() => { + if (cancelled) return; + setIsOnTeam(false); + setMembershipChecked(true); + }); + return () => { + cancelled = true; + }; + }, [isLoggedIn, accessToken, eventId, teamId]); + + return { isOnTeam, membershipChecked }; +} diff --git a/src/lib/batchEmailService.js b/src/lib/batchEmailService.js index 00a00c6c..d0bcd7e8 100644 --- a/src/lib/batchEmailService.js +++ b/src/lib/batchEmailService.js @@ -7,9 +7,11 @@ * - Email-only recipients: /api/admin/email/send */ -import { replacePlaceholders } from './messageTemplates'; +import { replacePlaceholders } from "./messageTemplates"; class BatchEmailService { + static MAX_PARALLEL_SENDS = 8; + constructor(apiServerUrl, accessToken, orgId) { this.apiServerUrl = apiServerUrl; this.accessToken = accessToken; @@ -26,17 +28,17 @@ class BatchEmailService { * @returns {Promise<{success: boolean, error?: string}>} */ async sendEmailToUser(user, message, subject, recipientType, eventId) { + const isEmailOnlyRecipient = + !user.id || user.source === "custom" || user.source === "csv"; + try { // Use shared utility to replace placeholders in message const processedMessage = replacePlaceholders(message, { eventId: eventId, volunteerId: user.id, - volunteerType: recipientType + volunteerType: recipientType, }); - // Determine if this is a user with an ID (registered user) or email-only recipient - const isEmailOnlyRecipient = !user.id || user.source === 'custom' || user.source === 'csv'; - let endpoint, requestBody; if (isEmailOnlyRecipient) { @@ -47,8 +49,8 @@ class BatchEmailService { message: processedMessage, subject: subject, recipient_type: recipientType, - name: user.name || user.email || 'Recipient', - volunteer_id: user.id || null + name: user.name || user.email || "Recipient", + volunteer_id: user.id || null, }; } else { // Use user ID endpoint for registered users @@ -59,16 +61,16 @@ class BatchEmailService { recipient_type: recipientType, recipient_id: user.id, email: user.email, - name: user.name || user.email || 'Recipient' + name: user.name || user.email || "Recipient", }; } const response = await fetch(endpoint, { - method: 'POST', + method: "POST", headers: { - 'Authorization': `Bearer ${this.accessToken}`, - 'Content-Type': 'application/json', - 'X-Org-Id': this.orgId, + Authorization: `Bearer ${this.accessToken}`, + "Content-Type": "application/json", + "X-Org-Id": this.orgId, }, body: JSON.stringify(requestBody), }); @@ -78,26 +80,29 @@ class BatchEmailService { return { success: data.success || true, data, - endpoint: isEmailOnlyRecipient ? 'email-only' : 'user-id' + endpoint: isEmailOnlyRecipient ? "email-only" : "user-id", }; } else { const errorData = await response.json().catch(() => ({})); - const errorMessage = errorData.message || errorData.error || `HTTP ${response.status}: ${response.statusText}`; + const errorMessage = + errorData.message || + errorData.error || + `HTTP ${response.status}: ${response.statusText}`; // Add context about which endpoint was used for better debugging - const contextualError = `${errorMessage} (via ${isEmailOnlyRecipient ? 'email-only' : 'user-ID'} endpoint)`; + const contextualError = `${errorMessage} (via ${isEmailOnlyRecipient ? "email-only" : "user-ID"} endpoint)`; return { success: false, error: contextualError, - endpoint: isEmailOnlyRecipient ? 'email-only' : 'user-id' + endpoint: isEmailOnlyRecipient ? "email-only" : "user-id", }; } } catch (error) { return { success: false, - error: `${error.message || 'Network error occurred'} (${isEmailOnlyRecipient ? 'email-only' : 'user-ID'} endpoint)`, - endpoint: isEmailOnlyRecipient ? 'email-only' : 'user-id' + error: `${error.message || "Network error occurred"} (${isEmailOnlyRecipient ? "email-only" : "user-ID"} endpoint)`, + endpoint: isEmailOnlyRecipient ? "email-only" : "user-id", }; } } @@ -112,55 +117,102 @@ class BatchEmailService { * @param {Function} onProgress - Callback for progress updates * @returns {Promise<{results: Array, summary: Object}>} */ - async sendBatchEmails(users, message, subject, recipientType, eventId, onProgress = () => {}) { - const results = []; + async sendBatchEmails( + users, + message, + subject, + recipientType, + eventId, + onProgress = () => {}, + ) { + const results = new Array(users.length); const summary = { total: users.length, successful: 0, failed: 0, - errors: [] + errors: [], }; - for (let i = 0; i < users.length; i++) { - const user = users[i]; - - // Update progress + if (users.length === 0) { onProgress({ - current: i + 1, + current: 0, + total: 0, + currentUser: null, + percentage: 100, + inFlight: 0, + concurrency: 0, + }); + return { results: [], summary }; + } + + const concurrency = Math.min( + BatchEmailService.MAX_PARALLEL_SENDS, + users.length, + ); + let nextIndex = 0; + let completed = 0; + let inFlight = 0; + + const reportProgress = (user) => { + onProgress({ + current: completed, total: users.length, - currentUser: user.name || user.email || user.id, - percentage: Math.round(((i + 1) / users.length) * 100) + currentUser: user?.name || user?.email || user?.id || null, + percentage: Math.round((completed / users.length) * 100), + inFlight, + concurrency, }); + }; - const result = await this.sendEmailToUser(user, message, subject, recipientType, eventId); - - const userResult = { - user: user, - success: result.success, - error: result.error, - data: result.data, - endpoint: result.endpoint // Track which endpoint was used - }; + const worker = async () => { + while (true) { + const index = nextIndex; + if (index >= users.length) { + return; + } - results.push(userResult); + nextIndex += 1; + const user = users[index]; + inFlight += 1; - if (result.success) { - summary.successful++; - } else { - summary.failed++; - summary.errors.push({ - user: user.name || user.email || user.id, - error: result.error - }); - } + const result = await this.sendEmailToUser( + user, + message, + subject, + recipientType, + eventId, + ); - // Small delay to avoid overwhelming the API - if (i < users.length - 1) { - await new Promise(resolve => setTimeout(resolve, 300)); + const userResult = { + user, + success: result.success, + error: result.error, + data: result.data, + endpoint: result.endpoint, + }; + + results[index] = userResult; + completed += 1; + inFlight -= 1; + + if (result.success) { + summary.successful += 1; + } else { + summary.failed += 1; + summary.errors.push({ + user: user.name || user.email || user.id, + error: result.error, + }); + } + + reportProgress(user); } - } + }; + + reportProgress(); + await Promise.all(Array.from({ length: concurrency }, () => worker())); - return { results, summary }; + return { results: results.filter(Boolean), summary }; } /** @@ -169,11 +221,12 @@ class BatchEmailService { * @returns {Array} Filtered users ready for email sending */ static filterEligibleUsers(users) { - return users.filter(user => - (user.isSelected !== undefined ? user.isSelected : true) && // Default to selected for community members - user.email && - user.email.trim() !== '' && - user.id // Must have an ID for the API endpoint + return users.filter( + (user) => + (user.isSelected !== undefined ? user.isSelected : true) && // Default to selected for community members + user.email && + user.email.trim() !== "" && + user.id, // Must have an ID for the API endpoint ); } @@ -183,11 +236,9 @@ class BatchEmailService { * @returns {Array} Filtered users ready for rejection email sending */ static filterNotSelectedUsers(users) { - return users.filter(user => - !user.isSelected && - user.email && - user.email.trim() !== '' && - user.id // Must have an ID for the API endpoint + return users.filter( + (user) => + !user.isSelected && user.email && user.email.trim() !== "" && user.id, // Must have an ID for the API endpoint ); } @@ -197,20 +248,20 @@ class BatchEmailService { * @returns {string} Error message if invalid, empty string if valid */ static validateMessage(message) { - if (!message || typeof message !== 'string') { - return 'Message content is required'; + if (!message || typeof message !== "string") { + return "Message content is required"; } - + const trimmedMessage = message.trim(); if (trimmedMessage.length === 0) { - return 'Message cannot be empty'; + return "Message cannot be empty"; } - + if (trimmedMessage.length > 10000) { - return 'Message is too long (max 10,000 characters)'; + return "Message is too long (max 10,000 characters)"; } - - return ''; + + return ""; } /** @@ -219,20 +270,20 @@ class BatchEmailService { * @returns {string} Error message if invalid, empty string if valid */ static validateSubject(subject) { - if (!subject || typeof subject !== 'string') { - return 'Subject is required'; + if (!subject || typeof subject !== "string") { + return "Subject is required"; } - + const trimmedSubject = subject.trim(); if (trimmedSubject.length === 0) { - return 'Subject cannot be empty'; + return "Subject cannot be empty"; } - + if (trimmedSubject.length > 200) { - return 'Subject is too long (max 200 characters)'; + return "Subject is too long (max 200 characters)"; } - - return ''; + + return ""; } /** @@ -242,18 +293,18 @@ class BatchEmailService { */ static getRecipientType(volunteerType) { const typeMap = { - 'mentors': 'mentor', - 'judges': 'judge', - 'volunteers': 'volunteer', - 'hackers': 'hacker', - 'sponsors': 'sponsor', - 'community members': 'community', - 'community': 'community', - 'slack': 'community' + mentors: "mentor", + judges: "judge", + volunteers: "volunteer", + hackers: "hacker", + sponsors: "sponsor", + "community members": "community", + community: "community", + slack: "community", }; return typeMap[volunteerType] || volunteerType; } } -export default BatchEmailService; \ No newline at end of file +export default BatchEmailService; diff --git a/src/lib/dateUtils.js b/src/lib/dateUtils.js index 7c8cd990..64b785fd 100644 --- a/src/lib/dateUtils.js +++ b/src/lib/dateUtils.js @@ -11,6 +11,7 @@ * native Date constructor. */ export function parseLocalDate(date) { + if (!date && date !== 0) return new Date(NaN); if (typeof date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(date)) { const [year, month, day] = date.split('-').map(Number); return new Date(year, month - 1, day); @@ -18,6 +19,15 @@ export function parseLocalDate(date) { return new Date(date); } +/** + * Returns true if the given value can be parsed into a valid Date. + */ +export function isValidDate(date) { + if (!date && date !== 0) return false; + const d = date instanceof Date ? date : parseLocalDate(date); + return !isNaN(d.getTime()); +} + /** * Check if a hackathon has ended by comparing "now" to end-of-day in the * hackathon's own timezone so viewers in other timezones don't see a diff --git a/src/lib/ga/index.js b/src/lib/ga/index.js index bcc9e32a..309e5525 100644 --- a/src/lib/ga/index.js +++ b/src/lib/ga/index.js @@ -27,7 +27,8 @@ export const EventCategory = { DONATION: 'donation', ERROR: 'error', USER: 'user', - SOCIAL: 'social' + SOCIAL: 'social', + ADMIN: 'admin' }; // Standard event actions for consistent naming @@ -93,13 +94,12 @@ export const pageview = (url, additionalParams = {}) => { ...additionalParams }; + // gtag('config', ...) already fires a page_view hit in GA4 — do NOT also + // call gtag('event', 'page_view', ...) or every navigation is counted twice. window.gtag('config', process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS, pageParams); - + // Facebook Pixel page view if (ReactPixel) ReactPixel.pageView(); - - // Track as an event as well for more flexibility in reporting - window.gtag('event', 'page_view', pageParams); } catch (error) { console.error('Error tracking pageview:', error); } diff --git a/src/lib/lexorank.js b/src/lib/lexorank.js new file mode 100644 index 00000000..c923345d --- /dev/null +++ b/src/lib/lexorank.js @@ -0,0 +1,75 @@ +/** + * Minimal LexoRank-style position strings for planning board ordering. + * + * Positions are strings like "p0000500" — sortable lexicographically. + * The "p" prefix keeps them non-numeric and prevents JS auto-coercion. + * + * Operations: + * between(a, b) — string that sorts between a and b + * before(a) — string that sorts before a + * after(a) — string that sorts after a + * initial(n) — evenly-spaced positions for n items + * needsRebalance(positions) — true when gaps are exhausted + */ + +const PREFIX = "p"; +const DIGITS = 7; +const MIN_VAL = 0; +const MAX_VAL = 9999999; +const REBALANCE_THRESHOLD = 1; // gap < 2 means we can't insert between + +function toInt(pos) { + if (!pos || !pos.startsWith(PREFIX)) return MIN_VAL; + return parseInt(pos.slice(PREFIX.length), 10) || MIN_VAL; +} + +function fromInt(n) { + const clamped = Math.max(MIN_VAL, Math.min(MAX_VAL, Math.round(n))); + return PREFIX + String(clamped).padStart(DIGITS, "0"); +} + +/** + * Return a position string that sorts strictly between a and b. + * Returns null if the gap is exhausted (needsRebalance will catch this). + */ +export function between(a, b) { + const lo = a ? toInt(a) : MIN_VAL; + const hi = b ? toInt(b) : MAX_VAL; + if (hi - lo < 2) return null; // gap exhausted + return fromInt(Math.floor((lo + hi) / 2)); +} + +/** Return a position that sorts before a (halves the gap to MIN_VAL). */ +export function before(a) { + const n = a ? toInt(a) : MAX_VAL; + return fromInt(Math.floor(n / 2)); +} + +/** Return a position that sorts after a (halfway to MAX_VAL). */ +export function after(a) { + const n = a ? toInt(a) : MIN_VAL; + if (n >= MAX_VAL) return null; // no room after + return fromInt(Math.floor((n + MAX_VAL) / 2)); +} + +/** Generate n evenly-spaced initial positions. */ +export function initial(n) { + if (n <= 0) return []; + const step = Math.floor(MAX_VAL / (n + 1)); + return Array.from({ length: n }, (_, i) => fromInt(step * (i + 1))); +} + +/** True when any adjacent gap is too small to insert between. */ +export function needsRebalance(positions) { + if (!positions || positions.length < 2) return false; + const sorted = [...positions].sort(); + for (let i = 0; i < sorted.length - 1; i++) { + if (toInt(sorted[i + 1]) - toInt(sorted[i]) < REBALANCE_THRESHOLD + 1) return true; + } + return false; +} + +/** Rebalance: return evenly-spaced replacements for the same number of positions. */ +export function rebalance(count) { + return initial(count); +} diff --git a/src/lib/messageTemplates.js b/src/lib/messageTemplates.js index 37e6e115..c0d77f87 100644 --- a/src/lib/messageTemplates.js +++ b/src/lib/messageTemplates.js @@ -278,16 +278,45 @@ export const replacePlaceholders = (message, { eventId, volunteerId, volunteerTy return result; }; +/** + * Convert the flat template array returned by the backend + * (GET /api/messages/admin/templates) into the MESSAGE_TEMPLATES grouped shape + * so existing consumers (filterTemplatesByType, template pickers) work + * unchanged. Archived templates are excluded. + * + * @param {Array} templateList - [{ id, title, category_key, category, applicable_roles, message, icon, status }] + * @returns {Object} { CATEGORY_KEY: { category, templates: [...] } } + */ +export const groupTemplatesByCategory = (templateList) => { + const grouped = {}; + (templateList || []).forEach((t) => { + if (t.status === 'archived') return; + const key = t.category_key || 'CUSTOM'; + if (!grouped[key]) { + grouped[key] = { category: t.category || 'Custom', templates: [] }; + } + grouped[key].templates.push({ + id: t.id, + title: t.title, + applicableRoles: t.applicable_roles || [], + message: t.message, + icon: t.icon || '✉️', + }); + }); + return grouped; +}; + /** * Filter templates by volunteer type * * @param {string} volunteerType - The type of volunteer to filter templates for + * @param {Object} [templates] - Templates object in MESSAGE_TEMPLATES shape (defaults to the hardcoded fallback) * @returns {Object} Filtered templates object with only applicable templates */ -export const filterTemplatesByType = (volunteerType) => { +export const filterTemplatesByType = (volunteerType, templates = MESSAGE_TEMPLATES) => { const filteredCategories = {}; - Object.entries(MESSAGE_TEMPLATES).forEach(([categoryKey, category]) => { + Object.entries(templates).forEach(([categoryKey, category]) => { const filteredTemplates = category.templates.filter(template => template.applicableRoles.includes(volunteerType) ); @@ -307,10 +336,11 @@ export const filterTemplatesByType = (volunteerType) => { * Get a specific template by ID * * @param {string} templateId - The ID of the template to retrieve + * @param {Object} [templates] - Templates object in MESSAGE_TEMPLATES shape (defaults to the hardcoded fallback) * @returns {Object|null} The template object or null if not found */ -export const getTemplateById = (templateId) => { - for (const category of Object.values(MESSAGE_TEMPLATES)) { +export const getTemplateById = (templateId, templates = MESSAGE_TEMPLATES) => { + for (const category of Object.values(templates)) { const template = category.templates.find(t => t.id === templateId); if (template) { return template; diff --git a/src/lib/planningStatus.js b/src/lib/planningStatus.js new file mode 100644 index 00000000..b667f752 --- /dev/null +++ b/src/lib/planningStatus.js @@ -0,0 +1,39 @@ +/** + * Card status — single source of truth for the option list, labels, and colors. + * + * Mirrors backend ALLOWED_CARD_STATUSES in model/planning.py. Any addition here + * must also be added there or the PATCH will return 400. + */ + +export const CARD_STATUSES = [ + { + id: "planned", + label: "Planned", + // Neutral grey — implies "queued, not yet started" + color: "#90a4ae", + // Used for icons / chip backgrounds + palette: "default", + }, + { + id: "in_progress", + label: "In progress", + color: "#1976d2", + palette: "info", + }, + { + id: "blocked", + label: "Blocked", + color: "#d32f2f", + palette: "error", + }, + { + id: "completed", + label: "Completed", + color: "#2e7d32", + palette: "success", + }, +]; + +export function statusMeta(id) { + return CARD_STATUSES.find((s) => s.id === id) || null; +} diff --git a/src/lib/textUtils.js b/src/lib/textUtils.js new file mode 100644 index 00000000..c676e8f3 --- /dev/null +++ b/src/lib/textUtils.js @@ -0,0 +1,43 @@ +/** + * Strip Markdown syntax down to clean, readable plain text. + * + * Used for teaser/preview snippets (e.g. event cards) where the text lives + * inside a clamped, single-anchor card and rendering real Markdown would + * either break the line-clamp or nest tags inside an (invalid HTML). + * For full-fidelity Markdown rendering use react-markdown instead. + */ +export function stripMarkdown(input) { + if (!input || typeof input !== "string") return ""; + + return ( + input + // Fenced code blocks -> keep inner text + .replace(/```[\w-]*\n?([\s\S]*?)```/g, "$1") + // Images ![alt](url) -> alt + .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1") + // Links [text](url) -> text + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") + // Reference-style links [text][ref] -> text + .replace(/\[([^\]]*)\]\[[^\]]*\]/g, "$1") + // Inline code `code` -> code + .replace(/`([^`]+)`/g, "$1") + // Bold / italic / strikethrough markers + .replace(/(\*\*\*|___)(.*?)\1/g, "$2") + .replace(/(\*\*|__)(.*?)\1/g, "$2") + .replace(/(\*|_)(.*?)\1/g, "$2") + .replace(/~~(.*?)~~/g, "$1") + // Headings (#, ##, ...) + .replace(/^\s{0,3}#{1,6}\s+/gm, "") + // Blockquote markers + .replace(/^\s{0,3}>\s?/gm, "") + // Unordered list bullets + .replace(/^\s*[-*+]\s+/gm, "") + // Ordered list markers + .replace(/^\s*\d+\.\s+/gm, "") + // Horizontal rules + .replace(/^\s*([-*_])\s*(?:\1\s*){2,}$/gm, "") + // Collapse whitespace/newlines into single spaces + .replace(/\s+/g, " ") + .trim() + ); +} diff --git a/src/pages/12-years-of-social-good/index.js b/src/pages/12-years-of-social-good/index.js new file mode 100644 index 00000000..81101e79 --- /dev/null +++ b/src/pages/12-years-of-social-good/index.js @@ -0,0 +1,949 @@ +import React from "react"; +import dynamic from "next/dynamic"; +import Link from "next/link"; +import { DATA, ERAS, LESSONS } from "../../components/TwelveYearsReport/data"; + +// Chart components are heavy (chart.js + react-chartjs-2). Code-split them +// into a single chunk that only loads on this page, client-only — there's +// no server-rendered chart output anyway. Next.js requires `dynamic` +// options to be inline object literals (no variable reference), so we +// repeat them per chart. +const YearlyChart = dynamic( + () => import("../../components/TwelveYearsReport/Charts").then((m) => m.YearlyChart), + { ssr: false, loading: () => }, +); +const TeammateChart = dynamic( + () => import("../../components/TwelveYearsReport/Charts").then((m) => m.TeammateChart), + { ssr: false, loading: () => }, +); +const TeamSizeChart = dynamic( + () => import("../../components/TwelveYearsReport/Charts").then((m) => m.TeamSizeChart), + { ssr: false, loading: () => }, +); +const SourceChart = dynamic( + () => import("../../components/TwelveYearsReport/Charts").then((m) => m.SourceChart), + { ssr: false, loading: () => }, +); +const UniversitiesChart = dynamic( + () => import("../../components/TwelveYearsReport/Charts").then((m) => m.UniversitiesChart), + { ssr: false, loading: () => }, +); +const CitiesChart = dynamic( + () => import("../../components/TwelveYearsReport/Charts").then((m) => m.CitiesChart), + { ssr: false, loading: () => }, +); +const SpecialtyChart = dynamic( + () => import("../../components/TwelveYearsReport/Charts").then((m) => m.SpecialtyChart), + { ssr: false, loading: () => }, +); +const DomainChart = dynamic( + () => import("../../components/TwelveYearsReport/Charts").then((m) => m.DomainChart), + { ssr: false, loading: () => }, +); +const TimingChart = dynamic( + () => import("../../components/TwelveYearsReport/Charts").then((m) => m.TimingChart), + { ssr: false, loading: () => }, +); +const RepeatChart = dynamic( + () => import("../../components/TwelveYearsReport/Charts").then((m) => m.RepeatChart), + { ssr: false, loading: () => }, +); +const TechChart = dynamic( + () => import("../../components/TwelveYearsReport/Charts").then((m) => m.TechChart), + { ssr: false, loading: () => }, +); + +function ChartSkeleton() { + return ( +
+
+ +
+ ); +} + +function bold(text) { + // Convert **bolded** segments in lesson body strings to + const parts = text.split(/(\*\*[^*]+\*\*)/g); + return parts.map((p, i) => + p.startsWith("**") && p.endsWith("**") ? ( + {p.slice(2, -2)} + ) : ( + {p} + ) + ); +} + +export default function TwelveYearsReport() { + const eventsByEra = ERAS.reduce((acc, era) => { + acc[era.key] = DATA.timeline.filter((e) => e.era === era.key); + return acc; + }, {}); + + return ( + <> +
+
+
+
A Field Report · 2014 → 2026
+

+ Twelve years of Opportunity Hack, in numbers. +

+

+ Across 21 hackathons on three continents, 4,076 hackers registered to build + technology for nonprofits — and 1,080 of them shipped. This is what their + data tells us about how social-good hackathons actually work. +

+
+ Who this is for: people who run hackathons (and want patterns they + can use), corporate teams considering a hackathon-for-good as a community + initiative, and anyone curious about how a volunteer-run event grew from one + San Jose weekend into a multi-year, multi-format program. If you're thinking + about hosting your own hackathon — or sponsoring ours — start here. +
+
+ 21 hackathons + 4,076 registrants + 1,080 shipped projects + 73 countries + 12 years +
+
+ + Want to host a hackathon for social good? + +
+
+ +
+
+
26.5%
+
Avg. completion rate
+
Registration → submitted project
+
+
+
39.2%
+
2025 completion rate
+
~2× the 2014–2022 baseline
+
+
+
219
+
Repeat hackers
+
Came to two or more events
+
+
+
5
+
Three-time finishers
+
Hackers who shipped 3+ times
+
+
+ + {/* SECTION 1: HISTORY/TIMELINE */} +
+
01 / The Story
+

From one San Jose weekend to a multi-format, year-round program.

+

+ Opportunity Hack started as a single hackathon in October 2014. It grew, stalled, + pivoted through COVID, and emerged with a more flexible event design. The history + breaks cleanly into four eras. +

+ +
+ {ERAS.map((era) => ( +
+
+
+
{era.name}
+
{era.years}
+
+
{era.tag}
+
+
+ {eventsByEra[era.key].map((ev, idx) => ( +
+
{ev.date}
+
{ev.title}
+
+ {ev.location.length > 50 ? ev.location.slice(0, 48) + "…" : ev.location} · {ev.type} +
+
+ ))} +
+
+ ))} +
+
+ + {/* SECTION 2: FUNNEL */} +
+
02 / The Funnel
+

Three of every four registrants never ship a project.

+

+ Across the full history, the registration → submission rate sits at 26.5%. + That's the universal hackathon problem: getting people to sign up is easy, getting them + to ship is hard. The good news is that completion has been climbing meaningfully since + 2022 — recent events run 30–40%, double the historical baseline. +

+ + + +
+ Pattern + Completion rate is structurally improving. The collapses in 2020 and 2022 + track real disruption (COVID, format reinvention). The recovery from 2023 onward + reflects a more deliberate event design — described in the lessons section below. +
+ +

+ Caveat: 2026's 71% rate is inflated because the dataset for that year is filtered to + invited/accepted-only flows. Treat post-2023 trend as directional. +

+
+ + {/* SECTION 3: TEAM FORMATION */} +
+
03 / The Biggest Lever
+

Team status at registration is the strongest predictor of who ships.

+

+ Hackers who register with a team submit at 39.7%. Those still looking + for one drop to 17.8%. Solo hackers crater at 9.8% — + a 4× gap. The same pattern shows up cleanly on the project side: solo projects ship + 46% of the time, while three-or-more-person teams ship 93%+ of the time. This is the + closest thing to a universal law in the dataset. +

+ +
+ + +
+ +
+ Universal Pattern + If a hackathon wants to move its completion rate, solving "I don't have a team" is + the highest-leverage problem. In this dataset, 959 registrants said they were solo + or looking — had they all found teams, expected submissions would have roughly doubled. +
+
+ + {/* SECTION 4: ACQUISITION */} +
+
04 / Where Hackers Come From
+

Listing platforms bring volume. Friends and direct outreach bring finishers.

+

+ Four channels do almost all the work: friend referrals (606), + DevPost (463), school/club (331), and + direct from organizer (278). But quality is wildly uneven. Hackers who + heard about the event on DevPost finish at 11.9%. Hackers who heard from + a friend finish at 34%. Hackers who heard directly from the organizer + finish at 39.2%. Returning alumni finish at 75%. +

+ + + +
+ Pattern + Public listing platforms are top-of-funnel awareness, not commitment. The + signups that actually ship come from people who heard about the event from someone they + trust — a friend, a professor, the organizer themselves. This pattern almost certainly + generalizes beyond OHack. +
+
+ + {/* SECTION 5: WHO */} +
+
05 / Who Shows Up
+

An anchor school, a developer-heavy crowd, two strong country bases.

+

+ Geography is heavily concentrated. Arizona State University alone supplies 985 + registrants — about 24% of the entire history. ASU + Arizona cities account for + over a quarter of all signups. Outside Arizona, San Jose, Chicago, and Atlanta form a + secondary cluster, each tied to a specific corporate-sponsored event series. This "anchor + school" pattern is common in nonprofit hackathons: a single committed academic partner + generates most of the volume. +

+ +
+ + +
+ +
+ + +
+ +
+ Observation + Full-stack developers are both the largest segment and the highest converter among + major roles (32%). Product managers and business folks register but rarely ship + (≤16%) — almost certainly because they can't deliver code alone, which loops directly + back to the team-formation problem in §03. +
+
+ + {/* SECTION 6: BEHAVIOR */} +
+
06 / Behavior
+

Late registrants finish. Repeat hackers compound.

+

+ The closer to the event a hacker registers, the more likely they are to ship. + Same-day registrants finish at 44%. Hackers who signed up 31–60 days early + finish at 18%. Long lead times signal weak intent. On the loyalty side, 219 + hackers have shown up to two or more events — and they convert at 42%, + nearly double the first-timer rate. But the multi-time finisher tail is small: only 51 + hackers have ever submitted projects at more than one event in twelve years. +

+ +
+ + +
+ +
+ Pattern + The "alumni hacker" is real but rare. 973 people have shipped exactly one + project, 46 have shipped two, and only 5 have shipped three. Nobody has ever shipped + four. Either people graduate out of the format (into mentor/judge/sponsor roles), or + there's a natural ceiling on hackathon participation that's worth understanding. +
+
+ + {/* SECTION 7: TECH */} +
+
07 / What Gets Built
+

JavaScript-heavy, modern, increasingly leaning on managed backends.

+

+ Across 13 years of submitted projects, JavaScript / Python / React dominate the long-tail + counts. But the modern additions — TypeScript, Next.js, Tailwind, Supabase, Firebase — + show projects increasingly leaning on managed services that compress time-to-MVP. This + stack evolution probably contributes to the rising completion rate in §02: it's measurably + easier to ship a working app today than it was a decade ago. +

+ + +
+ + {/* SECTION 8: LESSONS */} +
+
08 / What Hackathon Hosts Can Take From This
+

Five patterns the data argues for — broadly applicable beyond Opportunity Hack.

+

+ These aren't OHack-specific recommendations. They're patterns that emerge from twelve + years of registration and submission data, and they're likely to repeat at any hackathon + with a similar structure (multi-day, project-based, open registration, social-good or + technical theme). +

+ +
+ {LESSONS.map((l) => ( +
+
{l.n}
+
+

{l.title}

+

{bold(l.body)}

+
Evidence: {l.evidence}
+
+
+ ))} +
+
+ + {/* CTA SECTION */} +
+
09 / If You Take Anything From This Report
+

Three ways to put twelve years of social-good hackathons to work.

+

+ We've spent over a decade learning what works (and what doesn't) when you run a hackathon + for nonprofits. If any of the patterns above are useful for your team, here's how to put + them into practice — whether that's hosting your own event, partnering with us, or just + staying in touch. +

+ +
+ +
Host with us
+

Run a hackathon for your nonprofit or company

+

+ Tell us about the problem you want solved or the team you want to put through a + hackathon. We've co-hosted with corporate sponsors, university partners, and + nonprofits since 2014. +

+
Request a hackathon →
+ + + +
Sponsor
+

Fund a social-good hackathon

+

+ Sponsorship keeps OHack free for nonprofits and developers. Tiers include + dedicated mentor and judge slots for your team — a popular professional-development + perk that counts toward most ESG and corporate-volunteer programs. +

+
View sponsorship tiers →
+ + + +
Read more
+

What a hackathon for social good actually is

+

+ The companion page to this report — what makes a hackathon a "hackathon for + social good," how the OHack model works in detail, and details for the next + flagship event. +

+
Read the page →
+ +
+
+ +
+ Opportunity Hack · A Field Report from 12 Years + Sources: DevPost project + registrant exports, OHack hackathons table · 2014–2026 + + + Free software for nonprofits → + + +
+
+
+ + + + + ); +} + +export const getStaticProps = async () => { + const title = "12 Years of Social Good Hackathons: A Field Report | Opportunity Hack"; + const description = "Twelve years of data from 21 hackathons, 4,076 hackers, and 1,080 shipped projects. The patterns that emerged about completion rates, team formation, recruitment, and what hackathon hosts can learn."; + const canonicalUrl = "https://www.ohack.dev/12-years-of-social-good"; + const ogImage = "https://cdn.ohack.dev/ohack.dev/2024_hackathon_2.webp"; + const datePublished = "2026-04-26"; + + // Five lessons → FAQPage entries (visible in the page as the lessons section). + const faqEntries = [ + { + q: "What's the most important thing a hackathon host can do to improve completion rates?", + a: "Solve the team-formation problem before the event starts. Solo hackers finish at 9.8%. Teamed hackers finish at 39.7% — a 4× gap. Matchmaking flows, \"looking for a team\" boards, and forced team-join steps at registration would all raise the completion floor more than any other single change. Twelve years of Opportunity Hack data show this is the single highest-leverage intervention.", + }, + { + q: "Are listing platforms like DevPost good places to recruit hackers?", + a: "For awareness, yes. For finishers, no. DevPost-sourced registrants finish at 11.9% — about a third of the rate from friend referrals (34%) or direct organizer outreach (39%). Word-of-mouth and direct outreach are roughly 3× more efficient on a per-finisher basis. Use listing platforms for top-of-funnel awareness, but put your real recruitment energy into named-relationship outreach.", + }, + { + q: "When should hackathon registration close?", + a: "As late as possible — ideally the moment the event starts. Same-day registrants finish at 44%. Hackers who signed up over a month early finish at 18%. Many hackathons close registration two weeks early \"for planning purposes\" but that decision systematically cuts off the highest-intent segment of the audience. Late registration is high-intent registration. Plan for a day-zero surge instead of fighting it.", + }, + { + q: "How do you handle anchor-school risk in a hackathon?", + a: "Name your anchor partnerships explicitly, measure the concentration, and proactively cultivate two or three more before you have to. Opportunity Hack's relationship with Arizona State produces 24% of all registrations at a 35% completion rate — an excellent partnership, but also a single point of failure: a policy change, a curriculum shift, or a key contact leaving could halve the funnel overnight.", + }, + { + q: "What's the right way to engage hackathon alumni?", + a: "High-touch, named-relationship outreach — not mass email. Across twelve years of Opportunity Hack, only 51 hackers have ever shipped projects at more than one event. Five have shipped three times. Zero have shipped four. The \"alumni newsletter\" tactic doesn't fit this scale. Instead: invite specific alumni to mentor, advise nonprofits, judge, or recruit teammates from new cohorts. Help alumni stay in the ecosystem after they age out of competing.", + }, + ]; + + return { + props: { + title, + description, + canonical: canonicalUrl, + openGraphData: [ + { name: "title", property: "title", content: title, key: "title" }, + { name: "og:title", property: "og:title", content: title, key: "ogtitle" }, + { name: "og:description", property: "og:description", content: description, key: "ogdescription" }, + { name: "og:type", property: "og:type", content: "article", key: "ogtype" }, + { name: "image", property: "og:image", content: ogImage, key: "ognameimage" }, + { property: "og:image:width", content: "1200", key: "ogimagewidth" }, + { property: "og:image:height", content: "630", key: "ogimageheight" }, + { name: "og:url", property: "og:url", content: canonicalUrl, key: "ogurl" }, + { name: "twitter:card", property: "twitter:card", content: "summary_large_image", key: "twittercard" }, + { name: "twitter:site", property: "twitter:site", content: "@opportunityhack", key: "twittersite" }, + { name: "twitter:title", property: "twitter:title", content: title, key: "twittertitle" }, + { name: "twitter:description", property: "twitter:description", content: description, key: "twitterdesc" }, + { name: "twitter:image", property: "twitter:image", content: ogImage, key: "twitterimage" }, + { name: "twitter:image:alt", property: "twitter:image:alt", content: "Twelve years of Opportunity Hack — a field report on hackathons for social good", key: "twitterimagealt" }, + { name: "twitter:creator", property: "twitter:creator", content: "@opportunityhack", key: "twittercreator" }, + { name: "keywords", content: "how to host a hackathon, hackathon impact report, hackathon completion rate, social good hackathon, run a hackathon, organize a hackathon, hackathon best practices, hackathon data, hackathon case study, opportunity hack history", key: "keywords" }, + { name: "article:published_time", property: "article:published_time", content: datePublished, key: "articlepubtime" }, + { name: "article:author", property: "article:author", content: "Opportunity Hack", key: "articleauthor" }, + ], + structuredData: { + "@context": "https://schema.org", + "@graph": [ + { + "@type": "Article", + "@id": canonicalUrl + "#article", + headline: "Twelve Years of Opportunity Hack — A Field Report", + description, + image: ogImage, + datePublished, + dateModified: datePublished, + author: { + "@type": "Organization", + name: "Opportunity Hack", + url: "https://www.ohack.dev", + }, + publisher: { "@id": "https://www.ohack.dev/#organization" }, + mainEntityOfPage: { "@type": "WebPage", "@id": canonicalUrl + "#webpage" }, + about: { + "@type": "Thing", + name: "Hackathons for social good — registration, completion, and recruitment patterns", + }, + keywords: "hackathon hosting, hackathon completion rate, hackathon team formation, hackathon recruitment, social good hackathon, opportunity hack", + }, + { + "@type": "WebPage", + "@id": canonicalUrl + "#webpage", + url: canonicalUrl, + name: title, + description, + isPartOf: { "@type": "WebSite", "@id": "https://www.ohack.dev/#website" }, + }, + { + "@type": "BreadcrumbList", + itemListElement: [ + { "@type": "ListItem", position: 1, name: "Home", item: "https://www.ohack.dev" }, + { "@type": "ListItem", position: 2, name: "12 Years of Social Good", item: canonicalUrl }, + ], + }, + { + "@type": "FAQPage", + mainEntity: faqEntries.map((f) => ({ + "@type": "Question", + name: f.q, + acceptedAnswer: { "@type": "Answer", text: f.a }, + })), + }, + ], + }, + }, + }; +}; diff --git a/src/pages/_app.js b/src/pages/_app.js index 75dfa6be..389938e0 100644 --- a/src/pages/_app.js +++ b/src/pages/_app.js @@ -3,12 +3,15 @@ import dynamic from 'next/dynamic' import Head from "next/head"; import CssBaseline from "@mui/material/CssBaseline"; import { AuthProvider } from "@propelauth/react"; -import { GoogleReCaptchaProvider } from "react-google-recaptcha-v3"; import { ThemeProvider } from "@mui/material/styles"; import { Box } from "@mui/material"; import { useRouter } from "next/router"; import theme from "../assets/theme"; import { ShoppingCartProvider } from "../context/ShoppingCartContext"; +// Static import: SSR-safe (only registers axios interceptors in useEffect). +// IMPORTANT: must NOT be dynamic(ssr:false) — that disables SSR for the entire +// tree (empty , empty titles, CWV collapse; June 2026 incident). +import AxiosWrapper from '../components/axios-wrapper'; // Placeholder heights tuned to match the rendered NavBar/Footer so the shell // doesn't shift when these chunks load (major source of site-wide CLS). @@ -19,11 +22,6 @@ const FooterPlaceholder = () => ( // NOTE: Load dynamics below static imports to avoid eslint errors. -const AxiosWrapper = dynamic(() => import('../components/axios-wrapper'), { - ssr: false, - loading: () => null -}) - // SSR the NavBar shell so the layout above the fold is stable before hydration. // Auth-dependent avatar/login toggle is now wrapped in a fixed-width slot // inside Navbar.js so the flip on hydration doesn't shift layout. @@ -68,26 +66,69 @@ export default function MyApp({ Component, pageProps }) { ))} - {pageProps.title} + {pageProps.canonical && ( + + )} + + {pageProps.title && {pageProps.title}} + + {pageProps.structuredData && ( + - - - {/* Article schema for about page */} - - + + + + + @@ -1149,7 +1149,7 @@ export const getStaticProps = async () => { { name: "og:url", property: "og:url", - content: "https://ohack.dev/about/locations/asu-tempe-arizona", + content: "https://www.ohack.dev/about/locations/asu-tempe-arizona", key: "ogurl" }, { @@ -1183,7 +1183,7 @@ export const getStaticProps = async () => { "latitude": 33.4255, "longitude": -111.9400 }, - "url": "https://ohack.dev/about/locations/asu-tempe-arizona" + "url": "https://www.ohack.dev/about/locations/asu-tempe-arizona" } }, }; diff --git a/src/pages/about/mentors/index.js b/src/pages/about/mentors/index.js index e1fa5a52..37949725 100644 --- a/src/pages/about/mentors/index.js +++ b/src/pages/about/mentors/index.js @@ -1,4 +1,8 @@ import dynamic from "next/dynamic"; +import Head from "next/head"; +import Link from "next/link"; +import { Box } from "@mui/material"; +import { RefinedRoot, RefinedFonts, Eyebrow, Arrow } from "../../../components/design/refined"; const Mentorship = dynamic( () => import("../../../components/About/Mentorship/Mentorship"), @@ -7,18 +11,160 @@ const Mentorship = dynamic( } ); +const MentorChecklist = dynamic( + () => import("../../../components/About/Mentorship/MentorChecklist"), + { + ssr: false, + } +); + +const MentorTeamPanelDemo = dynamic( + () => import("../../../components/Teams/MentorTeamPanelDemo"), + { + ssr: false, + } +); -export default function Mentors() { - return ; +function Disclosure({ summary, children }) { + return ( +
+ + {summary} + + + +
{children}
+
+ ); } -export const getStaticProps = async () => { +// Visible FAQ content — the FAQPage JSON-LD in getStaticProps mirrors this +// array exactly. Visible UI ↔ schema parity is required for FAQ rich results. +const FAQ_DATA = [ + { + question: "What does a mentor do at an Opportunity Hack hackathon?", + answer: + "Mentors guide hackathon teams during the build phase — answering technical questions, helping debug tricky bugs, advising on architecture and scope, reviewing code, and unblocking teams when they get stuck. A typical mentor circulates among 3-5 teams over the weekend, spending 30-60 minutes with each team across multiple touchpoints. Mentors do not score or judge projects; that role belongs to judges, who are intentionally separate.", + }, + { + question: "What's the time commitment to mentor a hackathon?", + answer: + "Plan on 8-16 hours over the hackathon weekend. A typical Saturday is 4-6 hours of in-event mentoring, Sunday is another 4-6 hours, plus a 30-minute Friday kickoff briefing. We use a shift signup so mentors can come and go — you do not need to be present continuously. For online events, mentor availability is asynchronous through Discord and the time investment is similar but more flexible.", + }, + { + question: "Can I earn a Heart Certificate for mentoring?", + answer: + "Yes — Opportunity Hack issues Heart Certificates to mentors who demonstrate genuine engagement during the event. The bar is proactive helpfulness: reaching out to teams directly on Slack (for virtual events) or in person, reviewing code, offering architecture or debugging advice, and following up when a team gets stuck. Simply posting in a general channel that you're available does not qualify. The same standard applies whether you're mentoring remotely or on-site — judges don't just check that you showed up; they look for evidence that teams actually benefited from your involvement. After the hackathon, mentors who met that bar can request their Heart Certificate through the organizer team.", + }, + { + question: "Do I need specific technical expertise to be a mentor?", + answer: + "No single stack is required. We recruit mentors across full-stack web (React, Next.js, Node, Python, Ruby), mobile (iOS, Android, React Native), data engineering and ML, security, design, product management, and DevOps. When you sign up, you list your areas of expertise and we route team questions to mentors with matching skills. Mentors are expected to know their own stack well; nobody is expected to know every stack.", + }, + { + question: "Is mentoring at a hackathon paid?", + answer: + "No — mentoring is a volunteer role. Many mentors use it for their employer's ESG / corporate-volunteer-time programs and we provide an attendance letter on request. Sponsor companies can also secure dedicated mentor slots for their teams as part of a sponsorship package — that is a popular professional-development perk and counts toward most ESG goals.", + }, + { + question: "Can I mentor a hackathon remotely?", + answer: + "Yes. We run online-only events during the summer where all mentoring is remote (Discord and Slack), and most in-person events also have a remote mentor track for our distributed volunteers. Roughly 30% of our mentor pool participates remotely. In-person mentoring tends to be higher-impact for first-time teams (the in-room presence helps), but remote mentoring works well for code-review, architecture discussions, and senior engineers who can't travel.", + }, + { + question: "What if I've never mentored a hackathon before?", + answer: + "First-time mentors are welcome. We pair new mentors with veterans for their first event so you can shadow before you mentor solo. Before each event we run a 30-minute orientation covering mentor expectations, the team-matching system, escalation paths for hard questions, and the boundary between mentoring (helping the team build) and doing (writing code for the team). After the orientation most first-timers feel ready to jump in.", + }, + { + question: "Can I keep working with a team after the hackathon ends?", + answer: + "Yes — through our Founding Engineer program. Mentors who want to continue with a project after the event can apply to become Founding Engineers. The Founding Engineer program is unpaid volunteer work that continues for weeks or months post-hackathon, helping the project ship to production, fix bugs, and train the nonprofit's staff. Many of our most experienced mentors started by signing up for one weekend and then continuing with a project they connected with.", + }, + { + question: "What's the difference between a mentor and a judge at an Opportunity Hack hackathon?", + answer: + "Mentors guide teams during the build phase and do not score projects. Judges arrive Sunday afternoon to evaluate completed projects and do not engage with teams during the build. The roles are intentionally separated to avoid conflict-of-interest. Many volunteers do both at different events; doing both at the same event isn't allowed. If you want to evaluate projects rather than guide their construction, see our Hackathon Judge Opportunities page.", + }, +]; + +export default function Mentors() { + return ( + <> + + + + + + + {/* YOUR IMPACT, TRACKED */} +
+
+ Your impact, tracked +

A process we've built over the years

+

+ Mentoring at Opportunity Hack isn't ad-hoc. Every mentor gets a personal preparation + checklist and a per-team support panel other mentors can see live — so coverage is + even, nothing falls through the cracks, and your contributions are credited publicly to your name. +

+ +

1. Your personal mentor checklist

+

+ Use this before, during, and after the event to stay on track. The current phase highlights + automatically based on the upcoming event's schedule. +

+ + +

2. What you'll see on every team's page

+

+ On /hack/<event>/team/<team_id>, every mentor sees a shared support panel: + open concerns owned by a specific mentor so others don't duplicate effort, a 6-item coverage + checklist with attribution, a 4-criterion judging-readiness rubric (worst rating wins, so you + coach the weakest area first), and a public notes feed. Live preview below. +

+ +
+
+ + {/* FAQ */} +
+ FAQ for hackathon mentors +

Questions, answered

+
+ {FAQ_DATA.map((item, idx) => ( + +

{item.answer}

+
+ ))} +
+
+ + {/* CTA */} +
+
+

Ready to mentor?

+

+ Apply through any active event above, or browse adjacent ways to volunteer your senior expertise. +

+
+ See upcoming hackathons + Apply to judge instead +
+
+
+
+ + ); +} + +export const getStaticProps = async () => { const title = "Mentor Guide - Shape the Future of Tech for Good | Opportunity Hack"; const description = "Become a mentor at Opportunity Hack and guide talented teams building life-changing technology solutions for nonprofits. Share your expertise, develop leadership skills, and create lasting social impact through code."; + const canonicalUrl = "https://www.ohack.dev/about/mentors"; return { props: { - title: "Mentor Guide - Opportunity Hack", + title: title, description: description, + canonical: canonicalUrl, openGraphData: [ { name: "title", @@ -43,7 +189,7 @@ export const getStaticProps = async () => { property: "og:description", content: description, key: "ogdescription" - }, + }, { name: "image", property: "og:image", @@ -63,13 +209,13 @@ export const getStaticProps = async () => { { name: "url", property: "url", - content: "https://ohack.dev/about/mentors", + content: canonicalUrl, key: "url" }, { name: "og:url", property: "og:url", - content: "https://ohack.dev/about/mentors", + content: canonicalUrl, key: "ogurl" }, { @@ -104,7 +250,7 @@ export const getStaticProps = async () => { }, { name: "twitter:image:alt", - property: "twitter:image:alt", + property: "twitter:image:alt", content: "Mentor helping a participant at Opportunity Hack hackathon, demonstrating hands-on guidance and support", key: "twitterimagealt" }, @@ -113,34 +259,20 @@ export const getStaticProps = async () => { property: "twitter:creator", content: "@opportunityhack", key: "twittercreator" - } + } ], structuredData: { "@context": "https://schema.org", "@graph": [ - { - "@type": "Organization", - "@id": "https://ohack.dev/#organization", - "name": "Opportunity Hack", - "url": "https://ohack.dev", - "logo": { - "@type": "ImageObject", - "url": "https://cdn.ohack.dev/ohack.dev/2024_hackathon_4.webp" - }, - "sameAs": [ - "https://twitter.com/opportunityhack", - "https://github.com/opportunity-hack" - ] - }, { "@type": "WebPage", - "@id": "https://ohack.dev/about/mentors#webpage", - "url": "https://ohack.dev/about/mentors", + "@id": canonicalUrl + "#webpage", + "url": canonicalUrl, "name": title, "description": description, "isPartOf": { "@type": "WebSite", - "@id": "https://ohack.dev/#website" + "@id": "https://www.ohack.dev/#website" }, "about": { "@type": "EducationalOrganization", @@ -155,24 +287,35 @@ export const getStaticProps = async () => { "@type": "ListItem", "position": 1, "name": "Home", - "item": "https://ohack.dev" + "item": "https://www.ohack.dev" }, { - "@type": "ListItem", + "@type": "ListItem", "position": 2, "name": "About", - "item": "https://ohack.dev/about" + "item": "https://www.ohack.dev/about" }, { "@type": "ListItem", "position": 3, "name": "Mentors", - "item": "https://ohack.dev/about/mentors" + "item": canonicalUrl } ] + }, + { + "@type": "FAQPage", + "mainEntity": FAQ_DATA.map((faq) => ({ + "@type": "Question", + "name": faq.question, + "acceptedAnswer": { + "@type": "Answer", + "text": faq.answer, + }, + })), } ] } }, }; -}; \ No newline at end of file +}; diff --git a/src/pages/about/mentors/overview.js b/src/pages/about/mentors/overview.js index a4fba0ae..5b0caa3c 100644 --- a/src/pages/about/mentors/overview.js +++ b/src/pages/about/mentors/overview.js @@ -570,13 +570,13 @@ export const getStaticProps = async () => { { name: "url", property: "url", - content: "https://ohack.dev/about/mentors/overview", + content: "https://www.ohack.dev/about/mentors/overview", key: "url" }, { name: "og:url", property: "og:url", - content: "https://ohack.dev/about/mentors/overview", + content: "https://www.ohack.dev/about/mentors/overview", key: "ogurl" }, { @@ -669,9 +669,9 @@ export const getStaticProps = async () => { "@graph": [ { "@type": "Organization", - "@id": "https://ohack.dev/#organization", + "@id": "https://www.ohack.dev/#organization", "name": "Opportunity Hack", - "url": "https://ohack.dev", + "url": "https://www.ohack.dev", "logo": { "@type": "ImageObject", "url": "https://cdn.ohack.dev/ohack.dev/2024_hackathon_4.webp" @@ -701,17 +701,17 @@ export const getStaticProps = async () => { }, { "@type": "WebPage", - "@id": "https://ohack.dev/about/mentors/overview#webpage", - "url": "https://ohack.dev/about/mentors/overview", + "@id": "https://www.ohack.dev/about/mentors/overview#webpage", + "url": "https://www.ohack.dev/about/mentors/overview", "name": title, "description": description, "isPartOf": { "@type": "WebSite", - "@id": "https://ohack.dev/#website" + "@id": "https://www.ohack.dev/#website" }, "mainEntity": { "@type": "VideoObject", - "@id": "https://ohack.dev/about/mentors/overview#video" + "@id": "https://www.ohack.dev/about/mentors/overview#video" } }, { @@ -721,25 +721,25 @@ export const getStaticProps = async () => { "@type": "ListItem", "position": 1, "name": "Home", - "item": "https://ohack.dev" + "item": "https://www.ohack.dev" }, { "@type": "ListItem", "position": 2, "name": "About", - "item": "https://ohack.dev/about" + "item": "https://www.ohack.dev/about" }, { "@type": "ListItem", "position": 3, "name": "Mentors", - "item": "https://ohack.dev/about/mentors" + "item": "https://www.ohack.dev/about/mentors" }, { "@type": "ListItem", "position": 4, "name": "Overview", - "item": "https://ohack.dev/about/mentors/overview" + "item": "https://www.ohack.dev/about/mentors/overview" } ] } diff --git a/src/pages/about/process.js b/src/pages/about/process.js index 16e1fafa..0d66c39f 100644 --- a/src/pages/about/process.js +++ b/src/pages/about/process.js @@ -1,9 +1,7 @@ import React, { useEffect } from 'react'; import Head from 'next/head'; import Link from 'next/link'; -import { Typography, Grid, Paper, List, ListItem, ListItemText, Button, ThemeProvider, createTheme } from '@mui/material'; -import { styled } from '@mui/system'; - +import { Box } from '@mui/material'; import AssignmentIcon from '@mui/icons-material/Assignment'; import RateReviewIcon from '@mui/icons-material/RateReview'; import GroupIcon from '@mui/icons-material/Group'; @@ -12,10 +10,11 @@ import CodeIcon from '@mui/icons-material/Code'; import TimelineIcon from '@mui/icons-material/Timeline'; import { initFacebookPixel, trackEvent } from '../../lib/ga'; import dynamic from "next/dynamic"; +import { RefinedRoot, RefinedFonts, Eyebrow, Arrow } from '../../components/design/refined'; const MermaidDiagram = dynamic(() => import("../../components/MermaidDiagram"), { ssr: false, - loading: () =>

Loading diagram...

, + loading: () =>

Loading diagram…

, }); const chart = ` @@ -28,32 +27,36 @@ const chart = ` F --> G[Long-term Impact] `; - -const theme = createTheme({ - typography: { - fontSize: 15, - }, -}); - -const StyledPaper = styled(Paper)(({ theme }) => ({ - padding: theme.spacing(3), - marginBottom: theme.spacing(3), - height: '100%', - display: 'flex', - flexDirection: 'column', -})); - -const MermaidWrapper = styled('div')({ - display: 'flex', - justifyContent: 'center', - marginBottom: '2rem', -}); - -const IconWrapper = styled('div')({ - display: 'flex', - alignItems: 'center', - marginBottom: '1rem', -}); +const STEPS = [ + { n: "01", title: "Nonprofit application", icon: , content: "Nonprofits submit their ideas and challenges through our online form.", list: ["Share your organization's mission and vision", "Describe the specific technical challenges you face", "Explain how solving them will amplify your impact", "No tech expertise required — we're here to help"], link: { text: "Apply now", href: "/nonprofits/apply" } }, + { n: "02", title: "Application review", icon: , content: "Our team carefully evaluates each application to ensure a good fit.", list: ["Weekly review of new nonprofit applications", "Follow-up questions via email or Slack for clarity", "Assessment of feasibility and potential impact", "Consideration of available volunteer skills"] }, + { n: "03", title: "Nonprofit selection", icon: , content: "We choose diverse projects that benefit most from our hackathon model.", list: ["Notification sent to selected nonprofits", "A warm welcome to the Opportunity Hack community", "Access to our collaborative Slack workspace", "Initial briefing on the process and expectations"] }, + { n: "04", title: "Problem refinement", icon: , content: "We work closely with nonprofits to define clear, impactful goals.", list: ["One-on-one sessions with experienced mentors", "Clarifying scope, goals, and success criteria", "Guidance on a 3-minute pitch video", "Preliminary matching with skills and tech"] }, + { n: "05", title: "Hackathon event", icon: , content: "The main event — volunteers collaborate intensively on nonprofit projects.", list: ["Kickoff with inspiring nonprofit pitches", "Team formation based on skills and interests", "48 hours of focused development and prototyping", "Final presentations and judging by industry experts"] }, + { n: "06", title: "Project continuation", icon: , content: "We ensure projects have a path forward after the hackathon ends.", list: ["Winning teams invited to continue development", "~3 months of additional refinement and implementation", "Regular check-ins and progress updates", "Documentation and open-sourcing for wider benefit"] }, +]; + +const FAQS = [ + { q: "How can sponsors support Opportunity Hack?", a: (<>Sponsors make Opportunity Hack possible — through funding, mentorship, technology resources, or hosting events. Your support directly enables nonprofits to leverage cutting-edge technology for social good. Learn about sponsorship.) }, + { q: "What skills are needed to participate as a hacker?", a: "We welcome a diverse range of skills — software developers, UX/UI designers, data scientists, project managers, and anyone passionate about using technology for social impact. Whether you're a seasoned pro or just starting out, there's a place for you." }, + { q: "How can nonprofits prepare for the hackathon?", a: "Clearly define your challenges and goals. We'll guide you through the process, but it helps to think about your technical pain points, data you can share, and the impact a solution would have. Don't worry about the technical details — that's where our volunteers come in." }, + { q: "Is the code developed during Opportunity Hack open source?", a: "Yes — all code is open-source under the MIT License. This allows maximum collaboration, transparency, and the potential for solutions to benefit multiple organizations facing similar challenges." }, + { q: "What happens if our project isn't completed during the hackathon?", a: "The hackathon is just the beginning. While we aim for working prototypes during the event, winning teams are invited to continue development for ~3 months — for further refinement, testing, and implementation." }, +]; + +function Disclosure({ summary, children }) { + return ( +
+ + {summary} + + + +
+

{children}

+
+
+ ); +} export default function OpportunityHackProcess() { useEffect(() => { @@ -61,280 +64,139 @@ export default function OpportunityHackProcess() { }, []); const trackButtonClick = (buttonName) => { - trackEvent({ - action: "click_process", - params: { - event_category: "button", - event_label: buttonName, - }, - }); + trackEvent({ action: "click_process", params: { event_category: "button", event_label: buttonName } }); }; return ( - + <> - - Opportunity Hack Process: Empowering Nonprofits through Tech - Innovation - - - - - + Opportunity Hack Process: Empowering Nonprofits through Tech Innovation + + + + - - + + + -
- - Opportunity Hack: Innovating for Nonprofits - - - - Connecting Tech Talent with Nonprofit Needs - - - - - Our Unique Process - - - Opportunity Hack brings together nonprofits, developers, designers, - and tech professionals to create innovative solutions for social - good. Here's how we make it happen: - - - - - - - {[ - { - title: "1. Nonprofit Application", - content: - "Nonprofits submit their ideas and challenges through our online form.", - list: [ - "• Share your organization's mission and vision", - "• Describe specific technical challenges you face", - "• Explain how solving these challenges will amplify your impact", - "• No tech expertise required - we're here to help!", - ], - link: { text: "Apply Now", href: "/nonprofits/apply" }, - icon: , - }, - { - title: "2. Application Review", - content: - "Our expert team carefully evaluates each application to ensure a good fit.", - list: [ - "• Weekly review of new nonprofit applications", - "• Follow-up questions via email or Slack for clarity", - "• Assessment of project feasibility and potential impact", - "• Consideration of available volunteer skills and resources", - ], - icon: , - }, - { - title: "3. Nonprofit Selection", - content: - "We choose diverse projects that can benefit most from our hackathon model.", - list: [ - "• Notification sent to selected nonprofits", - "• Warm welcome to the Opportunity Hack community", - "• Access to our collaborative Slack workspace", - "• Initial briefing on the hackathon process and expectations", - ], - icon: , - }, - { - title: "4. Problem Refinement", - content: - "We work closely with nonprofits to define clear, impactful project goals.", - list: [ - "• One-on-one sessions with experienced Opportunity Hack mentors", - "• Clarification of project scope, goals, and success criteria", - "• Guidance on preparing an engaging 3-minute pitch video", - "• Preliminary matching with potential skills and technologies", - ], - icon: , - }, - { - title: "5. Hackathon Event", - content: - "The main event where volunteers collaborate intensively on nonprofit projects.", - list: [ - "• Kickoff with inspiring nonprofit pitches", - "• Team formation based on skills and interests", - "• 48 hours of focused project development and prototyping", - "• Ongoing communication between nonprofits and dev teams", - "• Final presentations and judging by industry experts", - ], - icon: , - }, - { - title: "6. Project Continuation", - content: - "We ensure projects have a path forward after the hackathon ends.", - list: [ - "• Winning teams invited to continue development", - "• Typically 3 months of additional refinement and implementation", - "• Regular check-ins and progress updates", - "• Support for deployment and handover to nonprofits", - "• Documentation and open-sourcing of solutions for wider benefit", - ], - icon: , - }, - ].map((section, index) => ( - - - - {section.icon} - - {section.title} - - - - {section.content} - - - {section.list.map((item, i) => ( - - - + + {/* HERO */} +
+ How it works +

+ Innovating for nonprofits. +

+

+ Opportunity Hack brings together nonprofits, developers, designers, and tech professionals to + build solutions for social good. Here's how a project moves from idea to lasting impact. +

+
+ + {/* FLOW DIAGRAM */} +
+
+ The journey +

Application to long-term impact

+ + + +
+
+ + {/* PROCESS STEPS */} +
+ Step by step +

Six stages, start to finish

+
+ {STEPS.map((s, i) => ( +
+
+ {s.n} + {s.icon} +
+

{s.title}

+

{s.content}

+
    + {s.list.map((item) => ( +
  • + + {item} +
  • ))} - - {section.link && ( -
    - - +
+ {s.link && ( +
+ trackButtonClick(s.link.text)}> + {s.link.text}
)} - - - ))} - - - - - Opportunity Hack 2024 Timeline - - -
-              {`
+              
+ ))} +
+
+ + {/* TIMELINE */} +
+
+ Annual timeline +

A year at Opportunity Hack

+ +
+                {`
                 gantt
-                  title Opportunity Hack 2024 Timeline
+                  title Opportunity Hack Timeline
                   dateFormat  YYYY-MM-DD
                   section Application
-                  Nonprofit Applications    :a1, 2024-05-01, 2024-08-31
-                  Weekly Application Review :a2, 2024-05-01, 2024-08-31
+                  Nonprofit Applications    :a1, 2026-05-01, 2026-08-31
+                  Weekly Application Review :a2, 2026-05-01, 2026-08-31
                   section Pre-Event
-                  Nonprofit Notification    :2024-09-01, 7d
-                  Problem Refinement        :2024-09-15, 14d
+                  Nonprofit Notification    :2026-09-01, 7d
+                  Problem Refinement        :2026-09-15, 14d
                   section Hackathon
-                  Hackathon Event           :milestone, m1, 2024-10-12, 2d
+                  Hackathon Event           :milestone, m1, 2026-10-11, 2d
                   section Post-Event
-                  Project Continuation      :2024-10-13, 90d
+                  Project Continuation      :2026-10-12, 90d
               `}
-            
- - - - - - Frequently Asked Questions - - {[ - { - q: "How can sponsors support Opportunity Hack?", - a: ( - <> - Sponsors play a crucial role in making Opportunity Hack - possible. You can contribute by providing funding, offering - mentorship, donating technology resources, or hosting events. - Your support directly enables nonprofits to leverage - cutting-edge technology for social good.{" "} - - Learn more about sponsorship opportunities - - . - - ), - }, - { - q: "What skills are needed to participate as a hacker?", - a: "We welcome a diverse range of skills! Software developers, UX/UI designers, data scientists, project managers, and anyone passionate about using technology for social impact can contribute. Whether you're a seasoned pro or just starting out, there's a place for you at Opportunity Hack.", - }, - { - q: "How can nonprofits prepare for the hackathon?", - a: "The key is to clearly define your challenges and goals. We'll guide you through the process, but it helps to think about your technical pain points, data you can share, and the impact a solution would have. Don't worry about the technical details – that's where our volunteers come in!", - }, - { - q: "Is the code developed during Opportunity Hack open source?", - a: "Yes, all code developed during Opportunity Hack is open-source and falls under the MIT License. This approach allows for maximum collaboration, transparency, and the potential for solutions to benefit multiple organizations facing similar challenges.", - }, - { - q: "What happens if our project isn't completed during the hackathon?", - a: "The hackathon is just the beginning! While we aim to create working prototypes during the event, winning teams are invited to continue development for about 3 months post-event. This extended period allows for further refinement, testing, and implementation of the solution.", - }, - ].map((faq, index) => ( -
- {faq.q} - {faq.a} -
- ))} -
- -
- - Ready to Make a Difference? - - - - -
-
- + + +
+

+ + {/* FAQ */} +
+ FAQ +

Common questions

+
+ {FAQS.map((faq, i) => ( + {faq.a} + ))} +
+
+ + {/* CTA */} +
+
+

Ready to make a difference?

+

+ Bring us your nonprofit's challenge — we'll match it with a team of builders. +

+ trackButtonClick("Apply Now")}> + Apply now + +
+
+ + ); -} \ No newline at end of file +} diff --git a/src/pages/about/style-guide/index.js b/src/pages/about/style-guide/index.js index 1d16a52b..29e48b93 100644 --- a/src/pages/about/style-guide/index.js +++ b/src/pages/about/style-guide/index.js @@ -67,7 +67,7 @@ export default function StyleGuide(){ property="og:image" content="https://cdn.ohack.dev/ohack.dev/logos/OpportunityHack_Logo_Dark_Blue_Square.png" /> - + - + diff --git a/src/pages/about/success-stories/index.js b/src/pages/about/success-stories/index.js index 0a329cfc..d5953a4e 100644 --- a/src/pages/about/success-stories/index.js +++ b/src/pages/about/success-stories/index.js @@ -1,69 +1,11 @@ import React, { useEffect } from "react"; import { useRouter } from "next/router"; import Head from "next/head"; -import { - Typography, - Card, - CardContent, - CardMedia, - Grid, - Button, - Container, - List, - ListItem, - ListItemIcon, - ListItemText, - Chip, - Box, -} from "@mui/material"; -import { styled } from "@mui/system"; +import Image from "next/image"; import Link from "next/link"; -import FavoriteIcon from "@mui/icons-material/Favorite"; -import GroupIcon from "@mui/icons-material/Group"; -import BusinessIcon from "@mui/icons-material/Business"; -import EmojiObjectsIcon from "@mui/icons-material/EmojiObjects"; -import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline"; -import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf"; -import SlideshowIcon from "@mui/icons-material/Slideshow"; -import FormatQuoteIcon from "@mui/icons-material/FormatQuote"; +import { Box } from "@mui/material"; import { initFacebookPixel, trackEvent } from "../../../lib/ga"; - -const TestimonialBox = styled(Box)(({ theme }) => ({ - backgroundColor: theme.palette.grey[100], - padding: theme.spacing(2), - borderRadius: theme.shape.borderRadius, - marginTop: theme.spacing(2), - marginBottom: theme.spacing(2), -})); - -const StyledCard = styled(Card)(({ theme }) => ({ - height: "100%", - display: "flex", - flexDirection: "column", - transition: "transform 0.3s ease-in-out", - "&:hover": { - transform: "scale(1.05)", - }, -})); - -const StyledCardMedia = styled(CardMedia)({ - paddingTop: "56.25%" // 16:9 aspect ratio -}); - -const StyledCardContent = styled(CardContent)({ - flexGrow: 1, -}); - -const LargerTypography = styled(Typography)(({ theme }) => ({ - fontSize: "1rem", - [theme.breakpoints.up("md")]: { - fontSize: "1.1rem", - }, -})); - -const PresentationChip = styled(Chip)(({ theme }) => ({ - margin: theme.spacing(0.5), -})); +import { RefinedRoot, RefinedFonts, Eyebrow, Arrow } from "../../../components/design/refined"; const successStories = [ { @@ -303,237 +245,130 @@ export default function SuccessStories() { return ( <> - - Opportunity Hack Success Stories: Tech Innovation for Social Good - - - - - + Opportunity Hack Success Stories: Tech Innovation for Social Good + + + + - - + + + - - - Opportunity Hack Success Stories - - - Transforming Nonprofits Through Technology - - - Real-world impact, powered by innovation and compassion - + + {/* HERO */} +
+ Success stories +

+ Real impact, shipped. +

+

+ How skilled tech volunteers and nonprofits team up at Opportunity Hack to build solutions that + keep working long after the hackathon ends. +

+
- - {successStories.map((story, index) => ( - - - - - - {story.title} - - - Nonprofit:{" "} - {story.nonprofit} - - - Tech Volunteers:{" "} - {story.hacker} - - - Sponsor: {story.sponsor} - - - Summary:{" "} - {story.summary} - - - Impact:{" "} - {story.impact} - - {story.presentations && story.presentations.length > 0 && ( -
- - Presentations: - - {story.presentations.map((presentation, i) => ( - - trackPresentationClick(presentation.title) - } - icon={ - presentation.type === "pdf" ? ( - - ) : ( - - ) - } - label={presentation.title} - component="a" - href={presentation.url} - target="_blank" - rel="noopener noreferrer" - clickable - /> - ))} + {/* STORIES */} +
+
+ {successStories.map((story, index) => ( +
+
+ {story.title} +
+
+ {story.nonprofit} +

{story.title}

+ +
+
+
Volunteers
+
{story.hacker}
- )} - - - {story.testimonial} - - +
+
Sponsors
+
{story.sponsor}
+
+
+ +

{story.summary}

+ +
+ Impact +

{story.impact}

+
+ +

“{story.testimonial}”

- Key Learnings: - - {story.learnings.slice(0, 3).map((learning, i) => ( - - - - - - - ))} - + {story.learnings?.length > 0 && ( +
    + {story.learnings.slice(0, 3).map((l, i) => ( +
  • + + {l} +
  • + ))} +
+ )} - - - - - ))} - + {story.presentations?.length > 0 && ( +
+ )} - - Join the Opportunity Hack Movement - - - - - See How We Transform Nonprofits Through Technology - - - Watch this video to understand how Opportunity Hack supports nonprofits. - - -