From 7e27ea66baf873a725a10c70ef650f4310d5b523 Mon Sep 17 00:00:00 2001 From: Greg V <6913307+gregv@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:22:01 -0700 Subject: [PATCH 01/19] Judge application: hard-gate behind LMS training certificates Judges must complete the two-video "Opportunity Hack Judging" bundle on lms.ohack.dev (Judge Intro + Using the judging tool) before the form unlocks. JudgeTrainingGate renders instead of the stepper/form until both certificate links verify live against the LMS's public certificate-verification query; links persist in formData, hydrate for returning judges, submit with the application, and show as links in admin review. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 4 + .../ApplicationForm/JudgeTrainingGate.js | 449 ++++++++++++++++ src/components/ApplicationForm/index.js | 6 + src/components/admin/ApplicationReviewCard.js | 11 + .../hack/[event_id]/judge-application.js | 480 ++++++++++-------- 5 files changed, 734 insertions(+), 216 deletions(-) create mode 100644 src/components/ApplicationForm/JudgeTrainingGate.js diff --git a/CLAUDE.md b/CLAUDE.md index 11957f9a..b740a774 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -327,6 +327,10 @@ Shared scaffolding lives in `src/components/ApplicationForm/`. Use these instead **Adding a new application field needs NO backend change.** Submissions POST to `/api/{type}/application//{submit,update}` → `handle_submit` → `create_or_update_volunteer` (`services/volunteers_service.py`), which persists the **entire** `volunteer_data` dict (`volunteer_doc.update(volunteer_data)` on create, `set(merge=True)` on update) — there is no field allowlist for volunteer/mentor/judge/hacker apps (unlike `save_hackathon`). New form fields flow through and are stored as-is. Mirror the `expertise`/`softwareEngineeringSpecifics` pattern: keep multi-selects as arrays in form state, join to a comma-string at submit (swapping an "Other" option for its free-text value), and split back on `loadPreviousSubmission`. To make a new field visible in admin review, add it to the type's `secondaryFields`/`additionalFields` + `labelMap` in `src/components/admin/ApplicationReviewCard.js` (empty values are auto-skipped, so legacy rows stay clean). **Mentor form** captures AI-tool usage (`aiTools[]`+`otherAiTools` → joined `aiToolsUsed`, plus `aiToolsExperience`) in Step 2. +### Judge form — LMS training gate (Aug 2026) + +The judge form is hard-gated behind LMS training: `JudgeTrainingGate` (`src/components/ApplicationForm/JudgeTrainingGate.js`) renders in place of the stepper + form (they don't mount at all) until BOTH certificate links verify. The bundle is `lms.ohack.dev/bundles/kn7ect1nhxqkcn2tp32tbypzdx8ckjx6` ("Opportunity Hack Judging": **Judge Intro** + **Using the judging tool**; each quiz pass issues a cert at `lms.ohack.dev/certificate/<64-hex>`). Verification is client-side against the LMS's public Convex query `certificates:getCertificateByShareToken` (`POST /api/query`; default deployment `majestic-trout-419.convex.cloud`, override via `NEXT_PUBLIC_LMS_CONVEX_URL`; anonymous + CORS-open by design — same query the LMS's own `/certificate/:token` page uses). Slot matching is by regex over the cert's `quizTitle`+`targetTitle` (`/judge\s*intro/i`, `/judging\s*tool/i` in `JUDGE_TRAINING_CERTS`) — keep in sync if LMS video/quiz titles change; duplicate tokens across the two fields are rejected. Cert URLs live in formData (`judgeTrainingIntroCertUrl`/`judgeTrainingToolCertUrl`) so they autosave, hydrate from a previous submission (returning judges auto-unlock via re-verification), and submit with the application (no backend change); submit also stamps `judgeTrainingCompleted` and `handleSubmit` re-checks `trainingVerified`. Both links render as clickable links in admin `ApplicationReviewCard` (judge `secondaryFields` + `labelMap` + all three `isLink` arrays). GA: `judge_app_training_link_click`, `judge_app_training_cert_verified` (intro|tool), `judge_app_training_unlocked`. + ### Judge form — in-person is a hard gate at physical venues `judge-application.js` has an `isVirtualEvent()` helper (location contains global/virtual/online/remote — same heuristic as the volunteer form's). At physical events, `validateAvailability` **blocks** (not warns) `inPerson !== "Yes"` and `canAttendJudging === "No"` on both step-Next and submit; the availability step shows blocking error alerts that route the applicant to the mentor application (mentors can be virtual) or `/hack` online events. "Partial" judging-window attendance stays allowed. Virtual events keep the soft-warning behavior. Don't reintroduce the old "remote judging is possible" soft warning at physical events. diff --git a/src/components/ApplicationForm/JudgeTrainingGate.js b/src/components/ApplicationForm/JudgeTrainingGate.js new file mode 100644 index 00000000..57f80d85 --- /dev/null +++ b/src/components/ApplicationForm/JudgeTrainingGate.js @@ -0,0 +1,449 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + Alert, + Box, + Button, + CircularProgress, + InputAdornment, + TextField, + Typography, +} from "@mui/material"; +import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded"; +import OpenInNewRounded from "@mui/icons-material/OpenInNewRounded"; +import SchoolRounded from "@mui/icons-material/SchoolRounded"; +import { Eyebrow } from "../design/refined"; +import { trackEvent } from "../../lib/ga"; +import { + emphasisPanelSx, + ghostButtonSx, + infoAlertSx, + primaryButtonSx, + refinedFieldSx, + stepLeadSx, + stepTitleSx, + successAlertSx, + warningAlertSx, +} from "./refinedStyles"; + +// The judge-training bundle on the OHack LMS (two videos, each with a +// knowledge check that issues a shareable certificate on a passing score). +export const JUDGE_TRAINING_BUNDLE_URL = + "https://lms.ohack.dev/bundles/kn7ect1nhxqkcn2tp32tbypzdx8ckjx6"; + +// Anonymous certificate verification — the LMS's Convex deployment exposes +// certificates:getCertificateByShareToken publicly (same query its own +// /certificate/:token page and unfurl bot use). CORS is open, so we can +// verify pasted links straight from the browser. +const LMS_CONVEX_QUERY_URL = `${ + process.env.NEXT_PUBLIC_LMS_CONVEX_URL || + "https://majestic-trout-419.convex.cloud" +}/api/query`; + +// Accepts a full LMS certificate URL or a bare 64-hex share token. +const CERT_TOKEN_RE = /^[0-9a-f]{64}$/i; +const CERT_URL_RE = /lms\.ohack\.dev\/certificate\/([0-9a-f]{64})/i; + +export const extractCertToken = (input) => { + const value = (input || "").trim(); + if (!value) return null; + if (CERT_TOKEN_RE.test(value)) return value.toLowerCase(); + const match = CERT_URL_RE.exec(value); + return match ? match[1].toLowerCase() : null; +}; + +// The two required certificates. `match` runs against the certificate's +// quizTitle + targetTitle (snapshotted at issuance), so it keeps working if +// the LMS titles get lightly reworded — keep these in sync with the bundle's +// video/quiz names ("Judge Intro" / "Using the judging tool"). +export const JUDGE_TRAINING_CERTS = [ + { + field: "judgeTrainingIntroCertUrl", + key: "intro", + videoTitle: "Judge Intro", + match: /judge\s*intro/i, + }, + { + field: "judgeTrainingToolCertUrl", + key: "tool", + videoTitle: "Using the judging tool", + match: /judging\s*tool/i, + }, +]; + +const verifyCertToken = async (token) => { + const response = await fetch(LMS_CONVEX_QUERY_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: "certificates:getCertificateByShareToken", + args: { shareToken: token }, + format: "json", + }), + }); + if (!response.ok) { + throw new Error(`Certificate lookup failed: ${response.status}`); + } + const body = await response.json(); + if (body?.status !== "success") { + throw new Error("Certificate lookup failed"); + } + // null value = token doesn't resolve to a certificate + return body.value || null; +}; + +const slotStatusMessage = (slot, spec) => { + switch (slot.status) { + case "invalid": + return "That doesn't look like an LMS certificate link — it should look like https://lms.ohack.dev/certificate/…"; + case "notfound": + return "We couldn't find a certificate at that link. Open your certificate on the LMS and copy its exact URL."; + case "duplicate": + return "This is the same certificate as the other field — each video issues its own certificate."; + case "mismatch": + return slot.cert + ? `This certificate is for “${slot.cert.targetTitle || slot.cert.quizTitle}” — paste the certificate from the “${spec.videoTitle}” video here.` + : `This certificate isn't for the “${spec.videoTitle}” video.`; + case "error": + return "We couldn't reach the LMS to verify this certificate. Check your connection and try again."; + default: + return ""; + } +}; + +/** + * Hard gate for the judge application: links applicants to the LMS judge + * training bundle and verifies both quiz certificates live before the + * application form is allowed to render. Certificate URLs live in the + * parent's formData (so they persist and submit with the application); + * verification state lives here. + * + * Judge-form only — rendered inside RefinedRoot, so .ohx-* classes and CSS + * vars are safe to use directly. + */ +const JudgeTrainingGate = ({ + values, + onValueChange, + onVerifiedChange, + eventId, +}) => { + // slot state per field: { status, cert } + // status: empty | invalid | checking | verified | mismatch | duplicate | notfound | error + const [slots, setSlots] = useState(() => + Object.fromEntries( + JUDGE_TRAINING_CERTS.map((spec) => [ + spec.field, + { status: "empty", cert: null }, + ]), + ), + ); + const [showInputs, setShowInputs] = useState(true); + const tokenCacheRef = useRef(new Map()); // token -> cert | null + const evaluateRunRef = useRef(0); + const verifiedRef = useRef(false); + const trackedVerifiedRef = useRef(new Set()); + + const introValue = values[JUDGE_TRAINING_CERTS[0].field] || ""; + const toolValue = values[JUDGE_TRAINING_CERTS[1].field] || ""; + + const evaluate = useCallback(async () => { + const runId = ++evaluateRunRef.current; + const rawValues = [introValue, toolValue]; + const tokens = rawValues.map(extractCertToken); + + const nextSlots = {}; + const toFetch = []; + + JUDGE_TRAINING_CERTS.forEach((spec, i) => { + const raw = (rawValues[i] || "").trim(); + const token = tokens[i]; + if (!raw) { + nextSlots[spec.field] = { status: "empty", cert: null }; + } else if (!token) { + nextSlots[spec.field] = { status: "invalid", cert: null }; + } else if (i > 0 && tokens.slice(0, i).includes(token)) { + nextSlots[spec.field] = { status: "duplicate", cert: null }; + } else if (tokenCacheRef.current.has(token)) { + const cert = tokenCacheRef.current.get(token); + nextSlots[spec.field] = resolveCertSlot(spec, cert); + } else { + nextSlots[spec.field] = { status: "checking", cert: null }; + toFetch.push({ spec, token }); + } + }); + + setSlots(nextSlots); + if (toFetch.length === 0) return; + + const results = await Promise.all( + toFetch.map(async ({ spec, token }) => { + try { + const cert = await verifyCertToken(token); + tokenCacheRef.current.set(token, cert); + return { spec, slot: resolveCertSlot(spec, cert) }; + } catch (err) { + console.error("LMS certificate verification failed:", err); + return { spec, slot: { status: "error", cert: null } }; + } + }), + ); + + if (evaluateRunRef.current !== runId) return; // stale — inputs changed + setSlots((prev) => { + const merged = { ...prev }; + results.forEach(({ spec, slot }) => { + merged[spec.field] = slot; + }); + return merged; + }); + }, [introValue, toolValue]); + + // Debounced re-verification whenever either link changes (covers typing, + // paste, localStorage restore, and previous-submission hydration). + useEffect(() => { + const handle = setTimeout(() => { + evaluate(); + }, 600); + return () => clearTimeout(handle); + }, [evaluate]); + + const allVerified = useMemo( + () => + JUDGE_TRAINING_CERTS.every( + (spec) => slots[spec.field]?.status === "verified", + ), + [slots], + ); + + useEffect(() => { + if (verifiedRef.current === allVerified) return; + verifiedRef.current = allVerified; + onVerifiedChange(allVerified); + if (allVerified) { + setShowInputs(false); + trackEvent({ + action: "judge_app_training_unlocked", + params: { event_id: eventId, page: "judge_application" }, + }); + } else { + setShowInputs(true); + } + }, [allVerified, onVerifiedChange, eventId]); + + // One GA ping per certificate the first time it verifies + useEffect(() => { + JUDGE_TRAINING_CERTS.forEach((spec) => { + if ( + slots[spec.field]?.status === "verified" && + !trackedVerifiedRef.current.has(spec.key) + ) { + trackedVerifiedRef.current.add(spec.key); + trackEvent({ + action: "judge_app_training_cert_verified", + params: { + event_label: spec.key, + event_id: eventId, + page: "judge_application", + }, + }); + } + }); + }, [slots, eventId]); + + const anyChecking = JUDGE_TRAINING_CERTS.some( + (spec) => slots[spec.field]?.status === "checking", + ); + + const renderCertField = (spec) => { + const slot = slots[spec.field] || { status: "empty", cert: null }; + const message = slotStatusMessage(slot, spec); + const hasError = Boolean(message); + return ( + + onValueChange(spec.field, e.target.value)} + placeholder="https://lms.ohack.dev/certificate/…" + error={hasError} + helperText={ + message || + (slot.status === "checking" + ? "Verifying with the LMS…" + : `Paste the certificate link you received for passing the “${spec.videoTitle}” knowledge check`) + } + sx={refinedFieldSx} + InputProps={{ + endAdornment: ( + + {slot.status === "checking" ? ( + + ) : slot.status === "verified" ? ( + + ) : null} + + ), + }} + /> + {slot.status === "verified" && slot.cert && ( + + ✓ Verified — {slot.cert.recipientName}, “{slot.cert.quizTitle}”, + score {Math.round(slot.cert.score)}% + + )} + + ); + }; + + return ( + + {allVerified && !showInputs ? ( + <> + } + sx={{ ...successAlertSx, mb: 2 }} + > + + Judge training verified.{" "} + {JUDGE_TRAINING_CERTS.map((spec) => `“${spec.videoTitle}”`).join( + " and ", + )}{" "} + are both complete — your certificates will be included with your + application. + + + + + ) : ( + <> + Before you apply + + Complete judge training first + + + Every judge completes two short training videos before applying —{" "} + Judge Intro and{" "} + Using the judging tool. Watch both, pass each + knowledge check, and paste your two certificate links below to + unlock the application. + + + + + + Open the judge training on our learning site (a free account — + any email works). + + + Watch both videos and pass each short knowledge check. + + + Each pass earns a certificate — open it and copy its link + (lms.ohack.dev/certificate/…). + + + Paste both links below. We verify them instantly. + + + + + + + + + {renderCertField(JUDGE_TRAINING_CERTS[0])} + {renderCertField(JUDGE_TRAINING_CERTS[1])} + + {anyChecking && ( + + + Verifying your certificates with the LMS… + + + )} + + {allVerified ? ( + } + sx={successAlertSx} + > + + Both certificates verified — the application is + unlocked below. + + + ) : ( + + + The rest of the application stays locked until both certificates + are verified. Questions? Ask in{" "} + + #ask-a-mentor on Slack + {" "} + or email{" "} + + questions@ohack.org + + . + + + )} + + )} + + ); +}; + +// Does this certificate satisfy this slot? Order matters: a real cert for +// the wrong video is a "mismatch" so the message can say what it IS for. +function resolveCertSlot(spec, cert) { + if (!cert) return { status: "notfound", cert: null }; + const haystack = `${cert.quizTitle || ""} ${cert.targetTitle || ""}`; + if (!spec.match.test(haystack)) return { status: "mismatch", cert }; + return { status: "verified", cert }; +} + +export default JudgeTrainingGate; diff --git a/src/components/ApplicationForm/index.js b/src/components/ApplicationForm/index.js index e5d86c30..32234887 100644 --- a/src/components/ApplicationForm/index.js +++ b/src/components/ApplicationForm/index.js @@ -6,4 +6,10 @@ export { default as PronounsPicker } from "./PronounsPicker"; export { CURATED_PRONOUNS } from "./PronounsPicker"; export { default as MealMenu } from "./MealMenu"; export { default as IntroVideoField } from "./IntroVideoField"; +export { default as JudgeTrainingGate } from "./JudgeTrainingGate"; +export { + JUDGE_TRAINING_BUNDLE_URL, + JUDGE_TRAINING_CERTS, + extractCertToken, +} from "./JudgeTrainingGate"; export { scrollToStepContent } from "./stepScroll"; diff --git a/src/components/admin/ApplicationReviewCard.js b/src/components/admin/ApplicationReviewCard.js index 0a70bdda..8f1a6468 100644 --- a/src/components/admin/ApplicationReviewCard.js +++ b/src/components/admin/ApplicationReviewCard.js @@ -121,6 +121,8 @@ const ApplicationReviewCard = ({ "state", "linkedinProfile", "introductionVideoUrl", + "judgeTrainingIntroCertUrl", + "judgeTrainingToolCertUrl", "backgroundAreas", ], additionalFields: [ @@ -402,6 +404,9 @@ const ApplicationReviewCard = ({ otherBackground: "Other Background", linkedinProfile: "LinkedIn Profile", introductionVideoUrl: "Intro Video", + judgeTrainingIntroCertUrl: "Training Cert: Judge Intro", + judgeTrainingToolCertUrl: "Training Cert: Judging Tool", + judgeTrainingCompleted: "Judge Training Completed", shirtSize: "T-Shirt Size", photoUrl: "Photo", status: "Status", @@ -606,6 +611,8 @@ const ApplicationReviewCard = ({ "website", "linkedinProfile", "introductionVideoUrl", + "judgeTrainingIntroCertUrl", + "judgeTrainingToolCertUrl", ].includes(field); return ( @@ -730,6 +737,8 @@ const ApplicationReviewCard = ({ "website", "linkedinProfile", "introductionVideoUrl", + "judgeTrainingIntroCertUrl", + "judgeTrainingToolCertUrl", ].includes(field); return ( @@ -1349,6 +1358,8 @@ const ApplicationReviewCard = ({ "portfolio", "website", "introductionVideoUrl", + "judgeTrainingIntroCertUrl", + "judgeTrainingToolCertUrl", ].includes(key); const displayVal = Array.isArray(val) ? val.join(", ") diff --git a/src/pages/hack/[event_id]/judge-application.js b/src/pages/hack/[event_id]/judge-application.js index 69ce3689..f808cbaf 100644 --- a/src/pages/hack/[event_id]/judge-application.js +++ b/src/pages/hack/[event_id]/judge-application.js @@ -64,6 +64,7 @@ import { } from "../../../components/design/refined"; import { IntroVideoField, + JudgeTrainingGate, OHackParticipationSelect, PronounsPicker, scrollToStepContent, @@ -158,6 +159,10 @@ const JudgeApplicationComponent = () => { const [passcodeInput, setPasscodeInput] = useState(""); const [passcodeError, setPasscodeError] = useState(""); + // LMS training gate — true once both certificate links verify against the + // LMS. The stepper + form only render when this is true. + const [trainingVerified, setTrainingVerified] = useState(false); + // reCAPTCHA integration const { initializeRecaptcha, @@ -199,6 +204,10 @@ const JudgeApplicationComponent = () => { linkedinProfile: "", shortBio: "", introductionVideoUrl: "", // "Tell us about you" video (upload or YouTube/Vimeo/Loom link) + // LMS judge-training certificate links (JudgeTrainingGate) — the whole + // form stays locked until both verify against the LMS + judgeTrainingIntroCertUrl: "", + judgeTrainingToolCertUrl: "", photoUrl: "", pronouns: "", country: "", @@ -319,6 +328,13 @@ const JudgeApplicationComponent = () => { photoUrl: prevData.photoUrl || "", introductionVideoUrl: prevData.introductionVideoUrl || "", + // Training certificates — re-verified by JudgeTrainingGate on + // load, which unlocks the form for returning judges + judgeTrainingIntroCertUrl: + prevData.judgeTrainingIntroCertUrl || "", + judgeTrainingToolCertUrl: + prevData.judgeTrainingToolCertUrl || "", + // Ensure event_id is always set event_id: event_id, }; @@ -1294,6 +1310,15 @@ const JudgeApplicationComponent = () => { const handleSubmit = async (e) => { if (e) e.preventDefault(); + // Belt-and-braces: the form isn't reachable until the training gate + // verifies, but never submit without it either. + if (!trainingVerified) { + setError( + "Please complete the judge training videos and verify both certificate links before submitting.", + ); + return; + } + if (!formData.judgingCommitment) { setError( "Please confirm you'll review each project and ask questions tied to the judging criteria.", @@ -1353,6 +1378,9 @@ const JudgeApplicationComponent = () => { backgroundAreas: backgroundAreasFormatted, // Include both formats for compatibility volunteer_type: "judge", isInPerson: formData.inPerson === "Yes", + // Cert URLs themselves ride along in formData + // (judgeTrainingIntroCertUrl / judgeTrainingToolCertUrl) + judgeTrainingCompleted: trainingVerified, // Don't set this value so that any updates will stay as-is on the backend // isSelected: false, // Default to false, admin will select later agreedToCodeOfConduct: Boolean(formData.codeOfConduct), @@ -2594,235 +2622,255 @@ const JudgeApplicationComponent = () => { )} - {/* Save/restore controls live beside the form they act on - (mt: 0 — the section provides the NavBar clearance) */} - + setFormData((prev) => ({ ...prev, [field]: value })) + } + onVerifiedChange={setTrainingVerified} + eventId={event_id} /> - - - {steps.map((label) => ( - - - {isMobile - ? activeStep === steps.indexOf(label) - ? label - : steps.indexOf(label) + 1 - : label} - - - ))} - - + {trainingVerified && ( + <> + {/* Save/restore controls live beside the form they act on + (mt: 0 — the section provides the NavBar clearance) */} + - - - What we expect - - What good judging looks like - - - Strong judging is what makes the work teams put in - meaningful — for them, and for the nonprofits they're - building for. As a judge, you'll review every project - you're assigned and ask questions that probe gaps in - the judging criteria so teams get real, useful - feedback. - - - We score on four pillars — Scope, Documentation, - Polish, and Security ( - - read the full rubric - - ). When the team hasn't covered a pillar in their - pitch, ask probing questions to find out: - - - Scope: "Which user problem does - this solve, and how did you decide what to leave - out?" - - - Documentation: "If a new - contributor joined Monday, where would they start?" - - - Polish: "Walk me through the happy - path — what does the nonprofit see?" - - - Security: "Where does sensitive - data live, and who has access?" - - - - - } - sx={{ ...infoAlertSx, mb: 4 }} - > - - New to judging at Opportunity Hack? Visit our{" "} - - Judges Information Page - {" "} - for the full process and commitment. - - - - {(error || recaptchaError) && ( - - {error || recaptchaError} - - )} - -
{ - e.preventDefault(); - handleSubmit(); - }} - > - - {getStepContent(activeStep)} + {steps.map((label) => ( + + + {isMobile + ? activeStep === steps.indexOf(label) + ? label + : steps.indexOf(label) + 1 + : label} + + + ))} + - - - + + {getStepContent(activeStep)} + + + + + + + +
- -
+ + )} )} From 11b8d8739684c7be16d977f8be3d517db41232f0 Mon Sep 17 00:00:00 2001 From: Greg V <6913307+gregv@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:26:52 -0700 Subject: [PATCH 02/19] Judge training gate: auto-detect LMS certificates via shared SSO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lms.ohack.dev signs in through the same PropelAuth instance as www.ohack.dev, and its Convex deployment trusts that issuer. The gate now calls the LMS with the judge's own access token (ensureExternalUser then getMyCertificates), fills the certificate fields itself, and re-checks on tab refocus and via an explicit "check again" button — no copy/paste needed. Manual paste stays as the fallback (different account, dev issuer mismatch, LMS unreachable). Verified values are never overwritten by auto-detect. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 6 +- .../ApplicationForm/JudgeTrainingGate.js | 485 ++++++++++++++++-- .../hack/[event_id]/judge-application.js | 1 + 3 files changed, 442 insertions(+), 50 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b740a774..68dd2df3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -329,7 +329,11 @@ Shared scaffolding lives in `src/components/ApplicationForm/`. Use these instead ### Judge form — LMS training gate (Aug 2026) -The judge form is hard-gated behind LMS training: `JudgeTrainingGate` (`src/components/ApplicationForm/JudgeTrainingGate.js`) renders in place of the stepper + form (they don't mount at all) until BOTH certificate links verify. The bundle is `lms.ohack.dev/bundles/kn7ect1nhxqkcn2tp32tbypzdx8ckjx6` ("Opportunity Hack Judging": **Judge Intro** + **Using the judging tool**; each quiz pass issues a cert at `lms.ohack.dev/certificate/<64-hex>`). Verification is client-side against the LMS's public Convex query `certificates:getCertificateByShareToken` (`POST /api/query`; default deployment `majestic-trout-419.convex.cloud`, override via `NEXT_PUBLIC_LMS_CONVEX_URL`; anonymous + CORS-open by design — same query the LMS's own `/certificate/:token` page uses). Slot matching is by regex over the cert's `quizTitle`+`targetTitle` (`/judge\s*intro/i`, `/judging\s*tool/i` in `JUDGE_TRAINING_CERTS`) — keep in sync if LMS video/quiz titles change; duplicate tokens across the two fields are rejected. Cert URLs live in formData (`judgeTrainingIntroCertUrl`/`judgeTrainingToolCertUrl`) so they autosave, hydrate from a previous submission (returning judges auto-unlock via re-verification), and submit with the application (no backend change); submit also stamps `judgeTrainingCompleted` and `handleSubmit` re-checks `trainingVerified`. Both links render as clickable links in admin `ApplicationReviewCard` (judge `secondaryFields` + `labelMap` + all three `isLink` arrays). GA: `judge_app_training_link_click`, `judge_app_training_cert_verified` (intro|tool), `judge_app_training_unlocked`. +The judge form is hard-gated behind LMS training: `JudgeTrainingGate` (`src/components/ApplicationForm/JudgeTrainingGate.js`) renders in place of the stepper + form (they don't mount at all) until BOTH certificates verify. The bundle is `lms.ohack.dev/bundles/kn7ect1nhxqkcn2tp32tbypzdx8ckjx6` ("Opportunity Hack Judging": **Judge Intro** + **Using the judging tool**; each quiz pass issues a cert at `lms.ohack.dev/certificate/<64-hex>`). + +**Detection is automatic first, manual paste is the fallback.** lms.ohack.dev is SSO-only through the SAME PropelAuth instance as www.ohack.dev (`auth.ohack.dev`), and the LMS's Convex deployment (default `majestic-trout-419.convex.cloud`, override `NEXT_PUBLIC_LMS_CONVEX_URL`) trusts it as a customJwt issuer with `EXTERNAL_AUTH_TRUST_EMAILS=true`. The gate therefore calls the Convex Functions HTTP API with the judge's own `accessToken` (`Authorization: Bearer`): `externalAuth:ensureExternalUser` (once per mount, links/creates the LMS account) then `certificates:getMyCertificates`; matched certs (newest `issuedAt` per slot, shareToken never reused across slots) seed the verification token cache and are written into the URL fields via `onValueChange` — the existing debounced `evaluate()` then verifies from cache with no extra network. Triggers: token-presence mount effect (`Boolean(accessToken)`, NEVER the raw token — PropelAuth rotates on refocus; all volatile inputs read through refs per the token-rotation-stability pattern), `visibilitychange` refocus (throttled 15s), and an explicit "check again" button (bypasses throttle). Auth-classified failures set `authFailedRef` to stop refocus retries — this is the **dev caveat**: localhost logs into a propelauthtest issuer prod LMS doesn't trust, so auto-detect lands in `unavailable` and manual paste (anonymous query, works everywhere) takes over. **Conflict rule:** a verified slot value is never overwritten by auto-detect; non-verified/broken values are. + +Manual verification is client-side against the LMS's public Convex query `certificates:getCertificateByShareToken` (anonymous + CORS-open by design — same query the LMS's own `/certificate/:token` page uses). Note `getMyCertificates` payloads carry NO `recipientName` — the verified summary line must render only present parts. Slot matching is by regex over the cert's `quizTitle`+`targetTitle` (`/judge\s*intro/i`, `/judging\s*tool/i` in `JUDGE_TRAINING_CERTS`) — keep in sync if LMS video/quiz titles change; duplicate tokens across the two fields are rejected. Cert URLs live in formData (`judgeTrainingIntroCertUrl`/`judgeTrainingToolCertUrl`) so they autosave, hydrate from a previous submission (returning judges auto-unlock via re-verification), and submit with the application (no backend change); submit also stamps `judgeTrainingCompleted` and `handleSubmit` re-checks `trainingVerified`. Both links render as clickable links in admin `ApplicationReviewCard` (judge `secondaryFields` + `labelMap` + all three `isLink` arrays). GA: `judge_app_training_link_click`, `judge_app_training_cert_verified` (intro|tool), `judge_app_training_unlocked`, `judge_app_training_autocheck` (label=mount|refocus|manual, value=match count), `judge_app_training_autodetected` (intro|tool), `judge_app_training_autocheck_failed` (auth|network|server), `judge_app_training_manual_fallback_open`. ### Judge form — in-person is a hard gate at physical venues diff --git a/src/components/ApplicationForm/JudgeTrainingGate.js b/src/components/ApplicationForm/JudgeTrainingGate.js index 57f80d85..678d76e1 100644 --- a/src/components/ApplicationForm/JudgeTrainingGate.js +++ b/src/components/ApplicationForm/JudgeTrainingGate.js @@ -16,6 +16,7 @@ import { } from "@mui/material"; import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded"; import OpenInNewRounded from "@mui/icons-material/OpenInNewRounded"; +import RefreshRounded from "@mui/icons-material/RefreshRounded"; import SchoolRounded from "@mui/icons-material/SchoolRounded"; import { Eyebrow } from "../design/refined"; import { trackEvent } from "../../lib/ga"; @@ -36,14 +37,27 @@ import { export const JUDGE_TRAINING_BUNDLE_URL = "https://lms.ohack.dev/bundles/kn7ect1nhxqkcn2tp32tbypzdx8ckjx6"; -// Anonymous certificate verification — the LMS's Convex deployment exposes -// certificates:getCertificateByShareToken publicly (same query its own -// /certificate/:token page and unfurl bot use). CORS is open, so we can -// verify pasted links straight from the browser. -const LMS_CONVEX_QUERY_URL = `${ +// The LMS's Convex deployment. Its Functions HTTP API is used two ways: +// - anonymously: certificates:getCertificateByShareToken verifies a pasted +// link (same query the LMS's own /certificate/:token page uses); +// - authenticated: lms.ohack.dev signs in through the SAME PropelAuth +// instance as www.ohack.dev (auth.ohack.dev, registered as a trusted +// customJwt issuer with EXTERNAL_AUTH_TRUST_EMAILS=true), so the judge's +// own accessToken can call externalAuth:ensureExternalUser and +// certificates:getMyCertificates via `Authorization: Bearer` to +// auto-detect earned certificates without any copy/paste. +// CORS is open on both. Dev caveat: localhost logs into a propelauthtest +// issuer the production LMS does not trust — authed calls fail there and the +// gate falls back to manual paste. +const LMS_CONVEX_BASE = process.env.NEXT_PUBLIC_LMS_CONVEX_URL || - "https://majestic-trout-419.convex.cloud" -}/api/query`; + "https://majestic-trout-419.convex.cloud"; +const LMS_CONVEX_QUERY_URL = `${LMS_CONVEX_BASE}/api/query`; +const LMS_CONVEX_MUTATION_URL = `${LMS_CONVEX_BASE}/api/mutation`; + +// Minimum gap between automatic checks (mount/refocus). The explicit +// "Check again" button bypasses it. +const AUTO_CHECK_THROTTLE_MS = 15000; // Accepts a full LMS certificate URL or a bare 64-hex share token. const CERT_TOKEN_RE = /^[0-9a-f]{64}$/i; @@ -57,6 +71,8 @@ export const extractCertToken = (input) => { return match ? match[1].toLowerCase() : null; }; +const certUrlForToken = (token) => `https://lms.ohack.dev/certificate/${token}`; + // The two required certificates. `match` runs against the certificate's // quizTitle + targetTitle (snapshotted at issuance), so it keeps working if // the LMS titles get lightly reworded — keep these in sync with the bundle's @@ -97,6 +113,85 @@ const verifyCertToken = async (token) => { return body.value || null; }; +// Authenticated Convex function call. Throws { code: "auth"|"network"|"server" } +// so callers can tell "this login isn't trusted by the LMS" (expected in dev, +// or on an untrusted issuer) apart from transient failures. +const callLmsAuthed = async (url, path, accessToken) => { + let response; + try { + response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ path, args: {}, format: "json" }), + }); + } catch (err) { + throw Object.assign(new Error(`LMS unreachable: ${err.message}`), { + code: "network", + }); + } + if (response.status === 401 || response.status === 403) { + throw Object.assign(new Error(`LMS auth rejected: ${response.status}`), { + code: "auth", + }); + } + if (!response.ok) { + throw Object.assign(new Error(`LMS call failed: ${response.status}`), { + code: "server", + }); + } + const body = await response.json(); + if (body?.status !== "success") { + const message = body?.errorMessage || "LMS call failed"; + throw Object.assign(new Error(message), { + code: /auth|unauthenticated|identity/i.test(message) ? "auth" : "server", + }); + } + return body.value; +}; + +const ensureExternalUserOnLms = (accessToken) => + callLmsAuthed( + LMS_CONVEX_MUTATION_URL, + "externalAuth:ensureExternalUser", + accessToken, + ); + +const fetchMyLmsCertificates = (accessToken) => + callLmsAuthed( + LMS_CONVEX_QUERY_URL, + "certificates:getMyCertificates", + accessToken, + ); + +// Assign the caller's certificates to the two required slots. Pure so it's +// unit-testable: newest issuedAt wins when a quiz was passed more than once, +// and a shareToken is never assigned to two slots (mirrors the manual +// duplicate rule). +export const matchCertsToSlots = (certs) => { + const used = new Set(); + const out = {}; + for (const spec of JUDGE_TRAINING_CERTS) { + const candidates = (certs || []) + .filter((c) => + spec.match.test(`${c.quizTitle || ""} ${c.targetTitle || ""}`), + ) + .filter( + (c) => + typeof c.shareToken === "string" && + !used.has(c.shareToken.toLowerCase()), + ) + .sort((a, b) => (b.issuedAt || 0) - (a.issuedAt || 0)); + if (candidates[0]) { + out[spec.field] = candidates[0]; + used.add(candidates[0].shareToken.toLowerCase()); + } + } + return out; +}; + const slotStatusMessage = (slot, spec) => { switch (slot.status) { case "invalid": @@ -116,21 +211,36 @@ const slotStatusMessage = (slot, spec) => { } }; +const SLOT_PROBLEM_STATUSES = [ + "invalid", + "notfound", + "duplicate", + "mismatch", + "error", +]; + /** * Hard gate for the judge application: links applicants to the LMS judge - * training bundle and verifies both quiz certificates live before the - * application form is allowed to render. Certificate URLs live in the - * parent's formData (so they persist and submit with the application); - * verification state lives here. + * training bundle and confirms both quiz certificates before the application + * form is allowed to render. + * + * Detection is automatic first: because both sites share one PropelAuth + * login, the gate calls the LMS with the judge's own access token + * (ensureExternalUser → getMyCertificates), fills the certificate URL fields + * itself, and re-checks when the tab regains focus — the judge just finishes + * the videos and comes back. Manual paste remains as a fallback (training + * done under a different account, dev environments, LMS unreachable). * - * Judge-form only — rendered inside RefinedRoot, so .ohx-* classes and CSS - * vars are safe to use directly. + * Certificate URLs live in the parent's formData (so they persist and submit + * with the application); verification state lives here. Judge-form only — + * rendered inside RefinedRoot, so .ohx-* classes and CSS vars are safe. */ const JudgeTrainingGate = ({ values, onValueChange, onVerifiedChange, eventId, + accessToken, }) => { // slot state per field: { status, cert } // status: empty | invalid | checking | verified | mismatch | duplicate | notfound | error @@ -143,11 +253,44 @@ const JudgeTrainingGate = ({ ), ); const [showInputs, setShowInputs] = useState(true); + // Auto-detect state: is a check in flight, and what did the last one find? + const [autoChecking, setAutoChecking] = useState(false); + const [autoOutcome, setAutoOutcome] = useState(null); // null | matched | partial | none | unavailable + const [manualOpen, setManualOpen] = useState(false); + const tokenCacheRef = useRef(new Map()); // token -> cert | null const evaluateRunRef = useRef(0); const verifiedRef = useRef(false); const trackedVerifiedRef = useRef(new Set()); + // Auto-detect plumbing. PropelAuth rotates the access token on tab refocus, + // and the parent's onValueChange is an inline arrow — both are read through + // refs so runAutoDetect stays identity-stable (CLAUDE.md token-rotation + // pattern) and the mount/refocus triggers never refire on re-renders. + const accessTokenRef = useRef(accessToken); + const valuesRef = useRef(values); + const slotsRef = useRef(slots); + const onValueChangeRef = useRef(onValueChange); + const ensureRanRef = useRef(false); // ensureExternalUser once per mount + const autoInFlightRef = useRef(false); // single-flight (also StrictMode) + const autoRunRef = useRef(0); // stale-response guard + const lastAutoCheckRef = useRef(0); // throttle clock + const authFailedRef = useRef(false); // stop refocus retries after an auth reject + const autoDetectedFieldsRef = useRef(new Set()); // GA once per slot + + useEffect(() => { + accessTokenRef.current = accessToken; + }, [accessToken]); + useEffect(() => { + valuesRef.current = values; + }, [values]); + useEffect(() => { + slotsRef.current = slots; + }, [slots]); + useEffect(() => { + onValueChangeRef.current = onValueChange; + }, [onValueChange]); + const introValue = values[JUDGE_TRAINING_CERTS[0].field] || ""; const toolValue = values[JUDGE_TRAINING_CERTS[1].field] || ""; @@ -204,7 +347,8 @@ const JudgeTrainingGate = ({ }, [introValue, toolValue]); // Debounced re-verification whenever either link changes (covers typing, - // paste, localStorage restore, and previous-submission hydration). + // paste, auto-detect fills, localStorage restore, and previous-submission + // hydration). Auto-detected tokens verify straight from the seeded cache. useEffect(() => { const handle = setTimeout(() => { evaluate(); @@ -212,6 +356,129 @@ const JudgeTrainingGate = ({ return () => clearTimeout(handle); }, [evaluate]); + // Ask the LMS which certificates this login has already earned, seed the + // verification cache, and fill the URL fields. All volatile inputs come + // through refs — identity must stay stable across parent re-renders. + const runAutoDetect = useCallback( + async (reason /* "mount" | "refocus" | "manual" */) => { + if (verifiedRef.current) return; // gate already open + if (autoInFlightRef.current) return; // single-flight + if (reason === "refocus" && authFailedRef.current) return; // untrusted issuer — don't spam + if ( + reason !== "manual" && + Date.now() - lastAutoCheckRef.current < AUTO_CHECK_THROTTLE_MS + ) { + return; + } + const token = accessTokenRef.current; + if (!token) return; + + const runId = ++autoRunRef.current; + autoInFlightRef.current = true; + setAutoChecking(true); + try { + // First-time identities need the account link before the query + // returns anything. Idempotent server-side; once per mount here. + if (!ensureRanRef.current) { + await ensureExternalUserOnLms(token); + ensureRanRef.current = true; + } + const certs = await fetchMyLmsCertificates(token); + if (autoRunRef.current !== runId) return; // stale + + const matched = matchCertsToSlots(certs); + for (const spec of JUDGE_TRAINING_CERTS) { + const cert = matched[spec.field]; + if (!cert) continue; + const shareToken = cert.shareToken.toLowerCase(); + tokenCacheRef.current.set(shareToken, cert); + // A verified value wins regardless of source — never overwrite it. + if (slotsRef.current[spec.field]?.status === "verified") continue; + // No-op write guard (also prevents any write→effect loop). + const currentToken = extractCertToken(valuesRef.current[spec.field]); + if (currentToken === shareToken) continue; + // Don't clobber a field the judge is actively typing in. + if ( + typeof document !== "undefined" && + document.activeElement?.name === spec.field + ) { + continue; + } + onValueChangeRef.current(spec.field, certUrlForToken(shareToken)); + if (!autoDetectedFieldsRef.current.has(spec.key)) { + autoDetectedFieldsRef.current.add(spec.key); + trackEvent({ + action: "judge_app_training_autodetected", + params: { + event_label: spec.key, + event_id: eventId, + page: "judge_application", + }, + }); + } + } + + const matchedCount = Object.keys(matched).length; + setAutoOutcome( + matchedCount >= JUDGE_TRAINING_CERTS.length + ? "matched" + : matchedCount > 0 + ? "partial" + : "none", + ); + lastAutoCheckRef.current = Date.now(); + trackEvent({ + action: "judge_app_training_autocheck", + params: { + event_label: reason, + value: matchedCount, + event_id: eventId, + page: "judge_application", + }, + }); + } catch (err) { + if (autoRunRef.current !== runId) return; + if (err?.code === "auth") authFailedRef.current = true; + setAutoOutcome("unavailable"); + lastAutoCheckRef.current = Date.now(); + trackEvent({ + action: "judge_app_training_autocheck_failed", + params: { + event_label: err?.code || "unknown", + event_id: eventId, + page: "judge_application", + }, + }); + console.error("LMS auto-detect failed:", err); + } finally { + if (autoRunRef.current === runId) { + autoInFlightRef.current = false; + setAutoChecking(false); + } + } + }, + [eventId], + ); + + // Kick off auto-detect when a login token becomes available. Keyed on + // token PRESENCE, never its value — PropelAuth rotates it on every refocus. + const hasToken = Boolean(accessToken); + useEffect(() => { + if (!hasToken) return; + runAutoDetect("mount"); + }, [hasToken, runAutoDetect]); + + // The magic moment: the judge finishes a video on the LMS tab and comes + // back here — re-check automatically (throttled). + useEffect(() => { + const onVisibilityChange = () => { + if (document.visibilityState === "visible") runAutoDetect("refocus"); + }; + document.addEventListener("visibilitychange", onVisibilityChange); + return () => + document.removeEventListener("visibilitychange", onVisibilityChange); + }, [runAutoDetect]); + const allVerified = useMemo( () => JUDGE_TRAINING_CERTS.every( @@ -258,6 +525,64 @@ const JudgeTrainingGate = ({ const anyChecking = JUDGE_TRAINING_CERTS.some( (spec) => slots[spec.field]?.status === "checking", ); + const anySlotProblem = JUDGE_TRAINING_CERTS.some((spec) => + SLOT_PROBLEM_STATUSES.includes(slots[spec.field]?.status), + ); + const verifiedSpecs = JUDGE_TRAINING_CERTS.filter( + (spec) => slots[spec.field]?.status === "verified", + ); + const remainingSpecs = JUDGE_TRAINING_CERTS.filter( + (spec) => slots[spec.field]?.status !== "verified", + ); + + // Manual paste is the fallback, not the headline: collapsed until asked + // for, but auto-expanded when auto-detect can't run or a value has a + // problem the judge needs to see (and whenever re-opened post-verification). + const manualVisible = + manualOpen || + allVerified || + autoOutcome === "unavailable" || + anySlotProblem; + + const openLmsButton = ( + + ); + + const checkAgainButton = ( + + ); const renderCertField = (spec) => { const slot = slots[spec.field] || { status: "empty", cert: null }; @@ -297,8 +622,18 @@ const JudgeTrainingGate = ({ variant="body2" sx={{ mt: -2, mb: 2, color: "#1b7f3b", fontWeight: 600 }} > - ✓ Verified — {slot.cert.recipientName}, “{slot.cert.quizTitle}”, - score {Math.round(slot.cert.score)}% + {/* Auto-detected certs (getMyCertificates) carry no + recipientName — render only the parts we have */} + ✓ Verified —{" "} + {[ + slot.cert.recipientName, + `“${slot.cert.quizTitle}”`, + Number.isFinite(slot.cert.score) + ? `score ${Math.round(slot.cert.score)}%` + : null, + ] + .filter(Boolean) + .join(", ")} )} @@ -341,61 +676,113 @@ const JudgeTrainingGate = ({ Every judge completes two short training videos before applying —{" "} Judge Intro and{" "} - Using the judging tool. Watch both, pass each - knowledge check, and paste your two certificate links below to - unlock the application. + Using the judging tool. Pass both knowledge checks + and we'll detect your certificates automatically. - Open the judge training on our learning site (a free account — - any email works). + Open the judge training on our learning site and sign in{" "} + with the same account you use here — it's the + same login. Watch both videos and pass each short knowledge check. - - Each pass earns a certificate — open it and copy its link - (lms.ohack.dev/certificate/…). - - Paste both links below. We verify them instantly. + Come back to this tab — we detect your certificates + automatically. (You can also paste the certificate links + manually.) - - + + {openLmsButton} + {(autoOutcome !== null || autoChecking) && checkAgainButton} - {renderCertField(JUDGE_TRAINING_CERTS[0])} - {renderCertField(JUDGE_TRAINING_CERTS[1])} - - {anyChecking && ( + {/* Auto-detect status */} + {autoChecking && autoOutcome === null && ( + + + + Checking your training record on the LMS… + + + )} + {autoOutcome === "none" && !allVerified && ( - Verifying your certificates with the LMS… + No training certificates found on your LMS account yet. Finish + both videos and come back to this tab — we'll pick them up + automatically. Did the training under a different account? Paste + your certificate links below instead. + + + )} + {autoOutcome === "unavailable" && ( + + + We couldn't check your LMS account automatically — paste your + certificate links below instead. + + + )} + {verifiedSpecs.length === 1 && ( + + + + “{verifiedSpecs[0].videoTitle}” verified — 1 of 2 complete. + {" "} + Finish “{remainingSpecs[0].videoTitle}” and come back. )} + {manualVisible ? ( + <> + {renderCertField(JUDGE_TRAINING_CERTS[0])} + {renderCertField(JUDGE_TRAINING_CERTS[1])} + + {anyChecking && ( + + + Verifying your certificates with the LMS… + + + )} + + ) : ( + + + + )} + {allVerified ? ( { } onVerifiedChange={setTrainingVerified} eventId={event_id} + accessToken={accessToken} /> {trainingVerified && ( From 5884f4ae45f25ad404935ba23dd350dabae86b64 Mon Sep 17 00:00:00 2001 From: Greg V <6913307+gregv@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:33:15 -0700 Subject: [PATCH 03/19] Admin nonprofit applications: readable review table with expandable ideas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review table crammed 10 raw-field columns into equal widths, leaving the Idea column — the one thing reviewers read — smushed to ~150px. - Merge legacy duplicate fields per row (name/contactName, organization/charityName, idea/technicalProblem) down to 4 columns so Idea gets ~46% of the table as a 3-line-clamped reading block - "Show more" expands long ideas in place; extra fields (technical problem, solution benefits, notes) open in a full-width reading panel - Organization cell carries the nonprofit-status dot; Contact cell links mailto; Submitted shows date + relative age - Search now also matches idea/notes text; result count shown; sort uses merged-field accessors and no longer mutates state in place - Drop the response console.log (application payloads carry PII) Co-Authored-By: Claude Fable 5 --- .../admin/NonprofitApplicationTable.js | 493 +++++++++++++++--- src/pages/admin/nonprofit/application.js | 52 +- 2 files changed, 468 insertions(+), 77 deletions(-) diff --git a/src/components/admin/NonprofitApplicationTable.js b/src/components/admin/NonprofitApplicationTable.js index ccaec76b..c547e201 100644 --- a/src/components/admin/NonprofitApplicationTable.js +++ b/src/components/admin/NonprofitApplicationTable.js @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState } from "react"; import { Table, TableBody, @@ -9,21 +9,62 @@ import { TableSortLabel, Paper, Button, - Chip, Tooltip, + Typography, + Box, + Link, } from "@mui/material"; import { styled } from "@mui/system"; +import EmailOutlinedIcon from "@mui/icons-material/EmailOutlined"; -const StyledTableContainer = styled(TableContainer)(({ theme }) => ({ +const NAVY = "#1B3A6B"; +const HAIRLINE = "rgba(22, 24, 29, 0.12)"; + +// Reviewer-first column plan: legacy duplicate fields are merged per row +// (name/contactName, organization/charityName, idea/technicalProblem) so the +// Idea column can take roughly half the table width as a readable text block. +const COLUMNS = [ + { id: "organization", label: "Organization", sortable: true, width: "17%" }, + { id: "name", label: "Contact", sortable: true, width: "17%" }, + { id: "idea", label: "Idea", sortable: false, width: "46%" }, + { id: "timestamp", label: "Submitted", sortable: true, width: "11%" }, + { id: "actions", label: "Actions", sortable: false, width: "9%" }, +]; + +// Rough threshold where a 3-line clamp starts truncating; beyond it we offer +// the expanded reading panel. +const CLAMP_CHAR_THRESHOLD = 220; + +const StyledTableContainer = styled(TableContainer)({ width: "100%", overflowX: "auto", - "& .MuiTable-root": { - minWidth: "100%", + border: `1px solid ${HAIRLINE}`, + borderRadius: 8, + boxShadow: "none", +}); + +const StyledTableHead = styled(TableHead)(({ theme }) => ({ + [theme.breakpoints.down("md")]: { + display: "none", }, })); +const HeadCell = styled(TableCell)(({ theme }) => ({ + padding: theme.spacing(1.5, 2), + fontSize: 12, + fontWeight: 700, + letterSpacing: "0.07em", + textTransform: "uppercase", + color: theme.palette.text.secondary, + backgroundColor: "#fafafa", + borderBottom: `1px solid ${HAIRLINE}`, + whiteSpace: "nowrap", +})); + const StyledTableCell = styled(TableCell)(({ theme }) => ({ - padding: theme.spacing(1, 2), + padding: theme.spacing(2), + verticalAlign: "top", + borderBottom: `1px solid ${HAIRLINE}`, [theme.breakpoints.down("md")]: { display: "flex", flexDirection: "column", @@ -32,13 +73,30 @@ const StyledTableCell = styled(TableCell)(({ theme }) => ({ padding: theme.spacing(1, 2), "&:before": { content: "attr(data-label)", - fontWeight: "bold", + fontSize: 11, + fontWeight: 700, + letterSpacing: "0.07em", + textTransform: "uppercase", + color: theme.palette.text.secondary, marginBottom: theme.spacing(0.5), }, }, })); const StyledTableRow = styled(TableRow)(({ theme }) => ({ + "&:hover": { + backgroundColor: "rgba(27, 58, 107, 0.03)", + }, + [theme.breakpoints.down("md")]: { + display: "flex", + flexDirection: "column", + borderBottom: `1px solid ${theme.palette.divider}`, + paddingBottom: theme.spacing(1), + }, +})); + +const DetailRow = styled(TableRow)(({ theme }) => ({ + backgroundColor: "rgba(27, 58, 107, 0.025)", [theme.breakpoints.down("md")]: { display: "flex", flexDirection: "column", @@ -46,6 +104,103 @@ const StyledTableRow = styled(TableRow)(({ theme }) => ({ }, })); +// Merged-field accessors shared with the page's sort logic. +export const applicationOrganization = (application) => + application.organization || application.charityName || ""; + +export const applicationContactName = (application) => + application.name || application.contactName || ""; + +export const applicationIdeaText = (application) => + application.idea || application.technicalProblem || ""; + +const applicationKey = (application) => + application.id || application.email || applicationOrganization(application); + +const formatSubmittedDate = (timestamp) => { + if (!timestamp) return null; + const date = new Date(timestamp); + if (isNaN(date.getTime())) return null; + return date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }); +}; + +const relativeAge = (timestamp) => { + if (!timestamp) return null; + const date = new Date(timestamp); + if (isNaN(date.getTime())) return null; + const diffMs = Date.now() - date.getTime(); + if (diffMs < 0) return null; + const hours = Math.floor(diffMs / (1000 * 60 * 60)); + if (hours < 1) return "just now"; + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 31) return `${days}d ago`; + const months = Math.floor(days / 30); + if (months < 12) return `${months}mo ago`; + return null; +}; + +const NonprofitStatus = ({ value }) => ( + + + {value ? "Nonprofit" : "Not a nonprofit"} + +); + +const DetailBlock = ({ label, text }) => { + if (!text) return null; + return ( + + + {label} + + + {text} + + + ); +}; + const NonprofitApplicationTable = ({ applications, orderBy, @@ -53,69 +208,279 @@ const NonprofitApplicationTable = ({ onRequestSort, onEditApplication, }) => { - const columns = [ - { id: "name", label: "Name", minWidth: 120 }, - { id: "idea", label: "Idea", minWidth: 150 }, - { id: "organization", label: "Organization", minWidth: 150 }, - { id: "email", label: "Email", minWidth: 150 }, - { id: "isNonProfit", label: "Is Nonprofit", minWidth: 100 }, - { id: "timestamp", label: "Timestamp", minWidth: 120 }, - { id: "notes", label: "Notes", minWidth: 150 }, - { id: "contactName", label: "Contact Name", minWidth: 150 }, - { id: "charityName", label: "Charity Name", minWidth: 150 }, - { id: "technicalProblem", label: "Technical Problem", minWidth: 150 }, - ]; + const [expandedKeys, setExpandedKeys] = useState(() => new Set()); + + const toggleExpanded = (key) => { + setExpandedKeys((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }; return ( - - - + +
+ - {columns.map((column) => ( - - onRequestSort(column.id)} - > - {column.label} - - + {COLUMNS.map((column) => ( + + {column.sortable ? ( + onRequestSort(column.id)} + sx={{ + "&.Mui-active": { color: NAVY }, + "&.Mui-active .MuiTableSortLabel-icon": { color: NAVY }, + }} + > + {column.label} + + ) : ( + column.label + )} + ))} - Actions - + - {applications.map((application) => ( - - {columns.map((column) => ( - - {column.id === "isNonProfit" ? ( - - ) : column.id === "notes" ? ( - - - {(application[column.id] || "").substring(0, 50)}... - - - ) : ( - application[column.id] || "-" - )} - - ))} - - - - - ))} + {applications.length === 0 && ( + + + + No applications match. Clear the search or refresh to see + every submission. + + + + )} + {applications.map((application) => { + const key = applicationKey(application); + const ideaText = applicationIdeaText(application); + const hasSecondProblemField = Boolean( + application.idea && application.technicalProblem + ); + const extraDetail = + hasSecondProblemField || + Boolean(application.solutionBenefits) || + Boolean(application.notes); + const expandable = + extraDetail || + ideaText.length > CLAMP_CHAR_THRESHOLD || + ideaText.includes("\n"); + const expanded = expandedKeys.has(key); + const submittedDate = formatSubmittedDate(application.timestamp); + const submittedAge = relativeAge(application.timestamp); + const contactName = applicationContactName(application); + + return ( + + + + + {applicationOrganization(application) || "—"} + + + + + + + + + {contactName || "—"} + + {application.email && ( + + {application.email} + + )} + + + + {ideaText ? ( + <> + toggleExpanded(key) : undefined + } + sx={{ + fontSize: 15, + lineHeight: 1.55, + whiteSpace: "pre-line", + overflowWrap: "break-word", + cursor: expandable ? "pointer" : "default", + ...(expanded + ? {} + : { + display: "-webkit-box", + WebkitLineClamp: 3, + WebkitBoxOrient: "vertical", + overflow: "hidden", + }), + }} + > + {ideaText} + + + {expandable && ( + + )} + {application.notes && ( + + Has notes + + )} + {hasSecondProblemField && ( + + + technical problem + + )} + + + ) : ( + + No idea provided + + )} + + + + + {submittedDate || "—"} + + {submittedAge && ( + + {submittedAge} + + )} + + + + + + {application.email && ( + + + + + + )} + + + + + {expanded && extraDetail && ( + + + + {/* The idea itself unclamps in the row above; this + panel only carries fields with no column. */} + {hasSecondProblemField && ( + + )} + + + + + + )} + + ); + })}
diff --git a/src/pages/admin/nonprofit/application.js b/src/pages/admin/nonprofit/application.js index 3b68fd1f..7fe7ac84 100644 --- a/src/pages/admin/nonprofit/application.js +++ b/src/pages/admin/nonprofit/application.js @@ -2,10 +2,27 @@ import React, { useState, useEffect } from "react"; import { useAuthInfo, withRequiredAuthInfo } from "@propelauth/react"; import { Box, Grid, CircularProgress, TextField, Button } from "@mui/material"; import AdminPage from "../../../components/admin/AdminPage"; -import NonprofitApplicationTable from "../../../components/admin/NonprofitApplicationTable"; +import NonprofitApplicationTable, { + applicationOrganization, + applicationContactName, + applicationIdeaText, +} from "../../../components/admin/NonprofitApplicationTable"; import NonprofitApplicationEditDialog from "../../../components/admin/NonprofitApplicationEditDialog"; import { Typography } from "@mui/material"; +// Sort on the merged fields the table displays, so legacy rows that only +// carry charityName/contactName don't all collapse to the top as "". +const sortValue = (application, key) => { + switch (key) { + case "organization": + return applicationOrganization(application).toLowerCase(); + case "name": + return applicationContactName(application).toLowerCase(); + default: + return application[key] || ""; + } +}; + const AdminNonprofitPage = withRequiredAuthInfo(({ userClass }) => { const { accessToken } = useAuthInfo(); const [applications, setApplications] = useState([]); @@ -42,7 +59,6 @@ const AdminNonprofitPage = withRequiredAuthInfo(({ userClass }) => { if (response.ok) { const data = await response.json(); - console.log(data); setApplications(data.applications || []); } else { throw new Error("Failed to fetch nonprofit applications"); @@ -117,10 +133,10 @@ const AdminNonprofitPage = withRequiredAuthInfo(({ userClass }) => { setSnackbar({ ...snackbar, open: false }); }; - const sortedApplications = applications + const sortedApplications = [...applications] .sort((a, b) => { - const valueA = a[orderBy] || ""; - const valueB = b[orderBy] || ""; + const valueA = sortValue(a, orderBy); + const valueB = sortValue(b, orderBy); if (valueA < valueB) { return order === "asc" ? -1 : 1; } @@ -130,12 +146,15 @@ const AdminNonprofitPage = withRequiredAuthInfo(({ userClass }) => { return 0; }) .filter((application) => { - const searchValue = filter.toLowerCase(); - return ( - (application.name || "").toLowerCase().includes(searchValue) || - (application.organization || "").toLowerCase().includes(searchValue) || - (application.email || "").toLowerCase().includes(searchValue) - ); + const searchValue = filter.toLowerCase().trim(); + if (!searchValue) return true; + return [ + applicationContactName(application), + applicationOrganization(application), + application.email || "", + applicationIdeaText(application), + application.notes || "", + ].some((field) => field.toLowerCase().includes(searchValue)); }); if (!isAdmin) { @@ -163,7 +182,7 @@ const AdminNonprofitPage = withRequiredAuthInfo(({ userClass }) => { setFilter(e.target.value)} /> @@ -172,9 +191,16 @@ const AdminNonprofitPage = withRequiredAuthInfo(({ userClass }) => {
{loading ? ( - + + + ) : ( + + {filter.trim() + ? `${sortedApplications.length} of ${applications.length} applications match` + : `${applications.length} applications`} + Date: Tue, 18 Aug 2026 20:33:57 -0700 Subject: [PATCH 04/19] Docs: nonprofit application review table contract in CLAUDE.md Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 68dd2df3..f8f3108f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,6 +105,10 @@ Patterns that must stay in place to keep Google Search Console CWV green: - Iframes (YouTube, Instagram, Calendar) must be wrapped in an aspect-ratio container (the existing pattern is `paddingBottom: '56.25%'` with `height: 0` + absolutely-positioned iframe) or given a fixed pixel height. - `initFacebookPixel` in `src/lib/ga/index.js` is idempotent via `pixelInitPromise`. Don't add `ReactPixel.init` calls outside of it. +## Admin Nonprofit Applications (`/admin/nonprofit/application`) + +Reviewer-first table (`src/components/admin/NonprofitApplicationTable.js`): 4 merged columns instead of the old 10 raw-field ones — legacy duplicate fields are collapsed per row via exported accessors `applicationOrganization` (`organization||charityName`), `applicationContactName` (`name||contactName`), `applicationIdeaText` (`idea||technicalProblem`). The page's sort/filter (`src/pages/admin/nonprofit/application.js`) uses the SAME accessors — keep them as the single source if fields change. Idea column takes ~46% width, 3-line clamp; "Show more" unclamps in place, and a full-width detail panel appears only for fields with no column (technicalProblem-alongside-idea, solutionBenefits, notes). Don't re-add per-field columns — that's the smushed-Idea regression this replaced. Header row hides below `md` (mobile uses the stacked `data-label` cards). No backend change; data is the `project_applications` collection via `GET /api/messages/npo/applications`. + ## Admin Profile Search (`/admin/profile`) Search-first people-finder. Single file: `src/pages/admin/profile/index.js`. Backend `GET /api/messages/admin/profiles` returns all users; filtering is client-side across ~14 fields (no server-side search). Auth: `userClass.hasPermission("profile.admin")`. From 8f5f3170c21d7b19347e512e97c722c3302ede0b Mon Sep 17 00:00:00 2001 From: Greg V <6913307+gregv@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:50:09 -0700 Subject: [PATCH 05/19] Dietary restrictions: shared dropdown across all four application forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hacker form's free-text dietary field with a shared DietaryRestrictionsSelect (multi-select of common restrictions + "Other" detail, "None" exclusive) and add the question to the mentor, judge, and volunteer forms — shown only at physical events when the applicant answers they'll attend in person, since meals are only served on site. The stored value stays a human-readable comma-joined string (legacy free-text submissions parse into the picker), so it flows through the existing submit/update endpoints with no backend change. Surfaced in admin review (ApplicationReviewCard) and edit (ApplicationEditDialog) for all four types. Co-Authored-By: Claude Fable 5 --- .../DietaryRestrictionsSelect.js | 171 ++++++++++++++++++ .../Hacker/LocationDemographicsStep.js | 13 +- .../DietaryRestrictionsSelect.test.js | 159 ++++++++++++++++ src/components/ApplicationForm/index.js | 6 + src/components/admin/ApplicationEditDialog.js | 3 + src/components/admin/ApplicationReviewCard.js | 5 + .../hack/[event_id]/judge-application.js | 15 ++ .../hack/[event_id]/mentor-application.js | 15 ++ .../hack/[event_id]/volunteer-application.js | 18 ++ 9 files changed, 397 insertions(+), 8 deletions(-) create mode 100644 src/components/ApplicationForm/DietaryRestrictionsSelect.js create mode 100644 src/components/ApplicationForm/__tests__/DietaryRestrictionsSelect.test.js diff --git a/src/components/ApplicationForm/DietaryRestrictionsSelect.js b/src/components/ApplicationForm/DietaryRestrictionsSelect.js new file mode 100644 index 00000000..6cbfbd06 --- /dev/null +++ b/src/components/ApplicationForm/DietaryRestrictionsSelect.js @@ -0,0 +1,171 @@ +import React, { useMemo } from "react"; +import { + Box, + Checkbox, + Chip, + FormControl, + FormHelperText, + InputLabel, + ListItemText, + MenuItem, + Select, + TextField, +} from "@mui/material"; + +// Common dietary restrictions offered across the application forms. +// "None" is exclusive; "Other" reveals a short free-text detail field. +// The stored value is a human-readable comma-joined string (the same +// shape the legacy free-text hacker field produced), so old submissions +// still parse and admins/caterers can read it directly. +export const DIETARY_RESTRICTION_OPTIONS = [ + "None", + "Vegetarian", + "Vegan", + "Pescatarian", + "Halal", + "Kosher", + "Gluten-free", + "Dairy-free", + "Nut allergy", + "Shellfish allergy", + "Egg allergy", + "Soy allergy", + "Other", +]; + +const OPTION_BY_LOWER = new Map( + DIETARY_RESTRICTION_OPTIONS.map((o) => [o.toLowerCase(), o]), +); + +// A few aliases so values from other parts of the system (meal +// dietary_tags, legacy free text) map onto the curated options. +const ALIASES = new Map([ + ["gluten free", "Gluten-free"], + ["dairy free", "Dairy-free"], + ["nut-free", "Nut allergy"], + ["nut free", "Nut allergy"], + ["no restrictions", "None"], + ["n/a", "None"], + ["na", "None"], +]); + +export const parseDietaryRestrictions = (value) => { + if (!value) return { selected: [], other: "" }; + const tokens = String(value) + .split(",") + .map((t) => t.trim()) + .filter(Boolean); + const selected = []; + const otherTokens = []; + for (const token of tokens) { + const lower = token.toLowerCase(); + const match = OPTION_BY_LOWER.get(lower) || ALIASES.get(lower); + if (match) { + if (!selected.includes(match)) selected.push(match); + } else { + otherTokens.push(token); + } + } + const other = otherTokens.join(", "); + if (other && !selected.includes("Other")) selected.push("Other"); + return { selected, other }; +}; + +export const serializeDietaryRestrictions = (selected, other) => { + const trimmedOther = (other || "").trim(); + const ordered = DIETARY_RESTRICTION_OPTIONS.filter((o) => + (selected || []).includes(o), + ); + // Swap "Other" for its free-text detail when provided (mirrors the + // expertise/skills "Other" submit pattern used across the forms). + const parts = ordered.map((o) => + o === "Other" && trimmedOther ? trimmedOther : o, + ); + return parts.join(", "); +}; + +const DietaryRestrictionsSelect = ({ + value, + onChange, + label = "Dietary restrictions (optional)", + helperText = "Select all that apply so we can plan meals for in-person attendees.", + required = false, + MenuProps, + sx = { mb: 3 }, +}) => { + const { selected, other } = useMemo( + () => parseDietaryRestrictions(value), + [value], + ); + + const emit = (nextSelected, nextOther) => { + onChange(serializeDietaryRestrictions(nextSelected, nextOther)); + }; + + const handleSelectChange = (event) => { + const raw = event.target.value; + let next = typeof raw === "string" ? raw.split(",") : raw; + if (next.includes("None") && !selected.includes("None")) { + // "None" was just picked — it stands alone. + next = ["None"]; + } else if (next.length > 1) { + next = next.filter((o) => o !== "None"); + } + emit(next, next.includes("Other") ? other : ""); + }; + + const handleOtherChange = (event) => { + emit(selected, event.target.value); + }; + + const labelId = "dietary-restrictions-label"; + + return ( + + + {label} + + {helperText && {helperText}} + + {selected.includes("Other") && ( + + )} + + ); +}; + +export default DietaryRestrictionsSelect; diff --git a/src/components/ApplicationForm/Hacker/LocationDemographicsStep.js b/src/components/ApplicationForm/Hacker/LocationDemographicsStep.js index 1debeeb5..962ff9fc 100644 --- a/src/components/ApplicationForm/Hacker/LocationDemographicsStep.js +++ b/src/components/ApplicationForm/Hacker/LocationDemographicsStep.js @@ -13,7 +13,7 @@ import { TextField, Typography, } from "@mui/material"; -import { MealMenu } from "../index"; +import { DietaryRestrictionsSelect, MealMenu } from "../index"; import { AGE_RANGE_OPTIONS, ARIZONA_COUNTY_OPTIONS, @@ -232,14 +232,11 @@ const LocationDemographicsStep = ({ {/* Only show dietary restrictions for non-online events */} {!eventData?.isOnlineEvent && ( - + setFormData((prev) => ({ ...prev, dietaryRestrictions: next })) + } /> )} diff --git a/src/components/ApplicationForm/__tests__/DietaryRestrictionsSelect.test.js b/src/components/ApplicationForm/__tests__/DietaryRestrictionsSelect.test.js new file mode 100644 index 00000000..ea365763 --- /dev/null +++ b/src/components/ApplicationForm/__tests__/DietaryRestrictionsSelect.test.js @@ -0,0 +1,159 @@ +import React from "react"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import DietaryRestrictionsSelect, { + DIETARY_RESTRICTION_OPTIONS, + parseDietaryRestrictions, + serializeDietaryRestrictions, +} from "../DietaryRestrictionsSelect"; + +describe("parseDietaryRestrictions", () => { + it("returns empty state for falsy values", () => { + expect(parseDietaryRestrictions("")).toEqual({ selected: [], other: "" }); + expect(parseDietaryRestrictions(null)).toEqual({ selected: [], other: "" }); + expect(parseDietaryRestrictions(undefined)).toEqual({ + selected: [], + other: "", + }); + }); + + it("parses comma-joined curated options", () => { + expect(parseDietaryRestrictions("Vegetarian, Gluten-free")).toEqual({ + selected: ["Vegetarian", "Gluten-free"], + other: "", + }); + }); + + it("matches options case-insensitively and via aliases", () => { + expect( + parseDietaryRestrictions("vegan, gluten free, nut-free").selected, + ).toEqual(["Vegan", "Gluten-free", "Nut allergy"]); + }); + + it("treats legacy free text as Other detail", () => { + const result = parseDietaryRestrictions( + "no pork, severe peanut allergy", + ); + expect(result.selected).toEqual(["Other"]); + expect(result.other).toBe("no pork, severe peanut allergy"); + }); + + it("mixes curated options with free-text leftovers", () => { + const result = parseDietaryRestrictions("Vegetarian, no cilantro"); + expect(result.selected).toEqual(["Vegetarian", "Other"]); + expect(result.other).toBe("no cilantro"); + }); + + it("dedupes repeated tokens", () => { + expect(parseDietaryRestrictions("Vegan, vegan").selected).toEqual([ + "Vegan", + ]); + }); +}); + +describe("serializeDietaryRestrictions", () => { + it("joins selections in canonical option order", () => { + expect( + serializeDietaryRestrictions(["Gluten-free", "Vegetarian"], ""), + ).toBe("Vegetarian, Gluten-free"); + }); + + it("swaps Other for its detail text when provided", () => { + expect( + serializeDietaryRestrictions(["Vegetarian", "Other"], "no cilantro"), + ).toBe("Vegetarian, no cilantro"); + }); + + it("keeps the literal Other when no detail is given", () => { + expect(serializeDietaryRestrictions(["Other"], "")).toBe("Other"); + }); + + it("round-trips through parse", () => { + const original = serializeDietaryRestrictions( + ["Vegan", "Nut allergy", "Other"], + "no cilantro", + ); + const { selected, other } = parseDietaryRestrictions(original); + expect(serializeDietaryRestrictions(selected, other)).toBe(original); + }); +}); + +describe("DietaryRestrictionsSelect", () => { + const setup = (value = "", props = {}) => { + const onChange = jest.fn(); + render( + , + ); + return { onChange }; + }; + + const openMenu = async (user) => { + await user.click(screen.getByLabelText(/dietary restrictions/i)); + return screen.getByRole("listbox"); + }; + + it("renders every curated option in the dropdown", async () => { + const user = userEvent.setup(); + setup(); + const listbox = await openMenu(user); + for (const option of DIETARY_RESTRICTION_OPTIONS) { + expect(within(listbox).getByText(option)).toBeInTheDocument(); + } + }); + + it("emits a serialized string when an option is selected", async () => { + const user = userEvent.setup(); + const { onChange } = setup(); + const listbox = await openMenu(user); + await user.click(within(listbox).getByText("Vegetarian")); + expect(onChange).toHaveBeenLastCalledWith("Vegetarian"); + }); + + it("adds to an existing selection", async () => { + const user = userEvent.setup(); + const { onChange } = setup("Vegetarian"); + const listbox = await openMenu(user); + await user.click(within(listbox).getByText("Halal")); + expect(onChange).toHaveBeenLastCalledWith("Vegetarian, Halal"); + }); + + it("makes None exclusive", async () => { + const user = userEvent.setup(); + const { onChange } = setup("Vegetarian, Halal"); + const listbox = await openMenu(user); + await user.click(within(listbox).getByText("None")); + expect(onChange).toHaveBeenLastCalledWith("None"); + }); + + it("drops None when another option is picked", async () => { + const user = userEvent.setup(); + const { onChange } = setup("None"); + const listbox = await openMenu(user); + await user.click(within(listbox).getByText("Vegan")); + expect(onChange).toHaveBeenLastCalledWith("Vegan"); + }); + + it("shows the detail field when Other is selected and emits its text", async () => { + const user = userEvent.setup(); + const { onChange } = setup("Other"); + const detail = screen.getByLabelText(/tell us more/i); + expect(detail).toBeInTheDocument(); + await user.type(detail, "x"); + expect(onChange).toHaveBeenLastCalledWith("x"); + }); + + it("hides the detail field when Other is not selected", () => { + setup("Vegetarian"); + expect(screen.queryByLabelText(/tell us more/i)).not.toBeInTheDocument(); + }); + + it("renders legacy free-text values as the Other detail", () => { + setup("no pork please"); + const detail = screen.getByLabelText(/tell us more/i); + expect(detail).toHaveValue("no pork please"); + }); +}); diff --git a/src/components/ApplicationForm/index.js b/src/components/ApplicationForm/index.js index 32234887..21bd0bb2 100644 --- a/src/components/ApplicationForm/index.js +++ b/src/components/ApplicationForm/index.js @@ -5,6 +5,12 @@ export { default as ProfileAutofillNotice } from "./ProfileAutofillNotice"; export { default as PronounsPicker } from "./PronounsPicker"; export { CURATED_PRONOUNS } from "./PronounsPicker"; export { default as MealMenu } from "./MealMenu"; +export { default as DietaryRestrictionsSelect } from "./DietaryRestrictionsSelect"; +export { + DIETARY_RESTRICTION_OPTIONS, + parseDietaryRestrictions, + serializeDietaryRestrictions, +} from "./DietaryRestrictionsSelect"; export { default as IntroVideoField } from "./IntroVideoField"; export { default as JudgeTrainingGate } from "./JudgeTrainingGate"; export { diff --git a/src/components/admin/ApplicationEditDialog.js b/src/components/admin/ApplicationEditDialog.js index 98a86cde..18490c51 100644 --- a/src/components/admin/ApplicationEditDialog.js +++ b/src/components/admin/ApplicationEditDialog.js @@ -254,6 +254,7 @@ const ApplicationEditDialog = ({ { name: 'mentorshipAreas', label: 'Preferred Mentorship Areas', type: 'multiselect', options: ['Technical Guidance', 'Project Planning', 'Team Dynamics', 'Presentation Skills', 'Career Advice', 'Industry Insights'] }, { name: 'availability', label: 'Availability', type: 'multiselect', options: ['Friday Evening', 'Saturday Morning', 'Saturday Afternoon', 'Saturday Evening', 'Sunday Morning', 'Sunday Afternoon'] }, { name: 'previousMentoring', label: 'Previous Mentoring Experience', type: 'textarea', rows: 3 }, + { name: 'dietaryRestrictions', label: 'Dietary Restrictions', type: 'text' }, { name: 'additionalInfo', label: 'Additional Information', type: 'textarea', rows: 3 } ] } @@ -279,6 +280,7 @@ const ApplicationEditDialog = ({ { name: 'judgingExperience', label: 'Previous Judging Experience', type: 'textarea', rows: 3 }, { name: 'criteriaPreferences', label: 'Preferred Judging Criteria', type: 'multiselect', options: ['Technical Innovation', 'Social Impact', 'User Experience', 'Business Viability', 'Presentation Quality', 'Team Collaboration'] }, { name: 'availability', label: 'Judging Availability', type: 'multiselect', options: ['Saturday Evening Presentations', 'Sunday Morning Presentations', 'Sunday Afternoon Final Judging'] }, + { name: 'dietaryRestrictions', label: 'Dietary Restrictions', type: 'text' }, { name: 'additionalInfo', label: 'Additional Information', type: 'textarea', rows: 3 } ] } @@ -298,6 +300,7 @@ const ApplicationEditDialog = ({ { name: 'availability', label: 'Availability', type: 'multiselect', options: ['Friday Setup', 'Saturday Full Day', 'Sunday Full Day', 'Sunday Cleanup'] }, { name: 'previousVolunteering', label: 'Previous Volunteering Experience', type: 'textarea', rows: 3 }, { name: 'motivation', label: 'Why do you want to volunteer?', type: 'textarea', rows: 3 }, + { name: 'dietaryRestrictions', label: 'Dietary Restrictions', type: 'text' }, { name: 'additionalInfo', label: 'Additional Information', type: 'textarea', rows: 3 } ] } diff --git a/src/components/admin/ApplicationReviewCard.js b/src/components/admin/ApplicationReviewCard.js index 8f1a6468..79903bef 100644 --- a/src/components/admin/ApplicationReviewCard.js +++ b/src/components/admin/ApplicationReviewCard.js @@ -89,6 +89,7 @@ const ApplicationReviewCard = ({ "portfolio", "motivation", "socialCauses", + "dietaryRestrictions", ], statusField: "isSelected", }, @@ -107,6 +108,7 @@ const ApplicationReviewCard = ({ "linkedin", "availability", "previousMentoring", + "dietaryRestrictions", ], statusField: "isSelected", }, @@ -132,6 +134,7 @@ const ApplicationReviewCard = ({ "additionalInfo", "pronouns", "otherBackground", + "dietaryRestrictions", "photoUrl", ], statusField: "isSelected", @@ -155,6 +158,7 @@ const ApplicationReviewCard = ({ "portfolio", "otherSocialCause", "shirtSize", + "dietaryRestrictions", "additionalInfo", ], statusField: "isSelected", @@ -408,6 +412,7 @@ const ApplicationReviewCard = ({ judgeTrainingToolCertUrl: "Training Cert: Judging Tool", judgeTrainingCompleted: "Judge Training Completed", shirtSize: "T-Shirt Size", + dietaryRestrictions: "Dietary Restrictions", photoUrl: "Photo", status: "Status", // Sponsor-specific fields diff --git a/src/pages/hack/[event_id]/judge-application.js b/src/pages/hack/[event_id]/judge-application.js index 2775547d..c0305c67 100644 --- a/src/pages/hack/[event_id]/judge-application.js +++ b/src/pages/hack/[event_id]/judge-application.js @@ -63,6 +63,7 @@ import { Stat, } from "../../../components/design/refined"; import { + DietaryRestrictionsSelect, IntroVideoField, JudgeTrainingGate, OHackParticipationSelect, @@ -193,6 +194,7 @@ const JudgeApplicationComponent = () => { availability: "", canAttendJudging: "", // New field for judging availability confirmation inPerson: "", + dietaryRestrictions: "", additionalInfo: "", companyName: "", codeOfConduct: false, @@ -314,6 +316,7 @@ const JudgeApplicationComponent = () => { availability: prevData.availability || "", canAttendJudging: prevData.canAttendJudging || "", inPerson: prevData.inPerson || "", + dietaryRestrictions: prevData.dietaryRestrictions || "", // Additional info additionalInfo: prevData.additionalInfo || "", @@ -1999,6 +2002,18 @@ const JudgeApplicationComponent = () => {
)} + + {/* Meals are served at the venue — only in-person judges need this */} + {!isVirtualEvent() && formData.inPerson === "Yes" && ( + + setFormData((prev) => ({ ...prev, dietaryRestrictions: next })) + } + MenuProps={refinedSelectMenuProps} + sx={refinedFieldSx} + /> + )}
); diff --git a/src/pages/hack/[event_id]/mentor-application.js b/src/pages/hack/[event_id]/mentor-application.js index 62eef401..c7449098 100644 --- a/src/pages/hack/[event_id]/mentor-application.js +++ b/src/pages/hack/[event_id]/mentor-application.js @@ -53,6 +53,7 @@ import { Stat, } from "../../../components/design/refined"; import { + DietaryRestrictionsSelect, OHackParticipationSelect, PronounsPicker, scrollToStepContent, @@ -173,6 +174,7 @@ const MentorApplicationComponent = () => { picture: "", linkedin: "", inPerson: "", + dietaryRestrictions: "", expertise: [], // Changed from string to array otherExpertise: "", // New field for "Other" option participationCount: "", @@ -550,6 +552,7 @@ const MentorApplicationComponent = () => { picture: prevData.photoUrl || prevData.picture || "", linkedin: prevData.linkedinProfile || prevData.linkedin || "", inPerson: prevData.isInPerson ? "Yes!" : "No, I'll be virtual", + dietaryRestrictions: prevData.dietaryRestrictions || "", expertise: (prevData.expertise || "") .split(", ") .filter(Boolean), @@ -1545,6 +1548,18 @@ const MentorApplicationComponent = () => { )} + {/* Meals are provided on site — only in-person mentors need this */} + {!isVirtualEvent() && formData.inPerson === "Yes!" && ( + + setFormData((prev) => ({ ...prev, dietaryRestrictions: next })) + } + MenuProps={refinedSelectMenuProps} + sx={refinedFieldSx} + /> + )} + {/* Location fields - conditional labels and requirements */} diff --git a/src/pages/hack/[event_id]/volunteer-application.js b/src/pages/hack/[event_id]/volunteer-application.js index 659f0b90..0f8e6ef7 100644 --- a/src/pages/hack/[event_id]/volunteer-application.js +++ b/src/pages/hack/[event_id]/volunteer-application.js @@ -49,6 +49,7 @@ import FormPersistenceControls from "../../../components/FormPersistenceControls import { useFormPersistence } from "../../../hooks/use-form-persistence"; import { useRecaptcha } from "../../../hooks/use-recaptcha"; import { + DietaryRestrictionsSelect, PronounsPicker, scrollToStepContent, } from "../../../components/ApplicationForm"; @@ -177,6 +178,7 @@ const VolunteerApplicationComponent = () => { country: "", state: "", inPerson: "", + dietaryRestrictions: "", experienceLevel: "", shirtSize: "", volunteerType: [], @@ -1044,6 +1046,7 @@ const VolunteerApplicationComponent = () => { state: prevData.state || "", inPerson: prevData.inPerson || (prevData.isInPerson ? "Yes" : "No"), + dietaryRestrictions: prevData.dietaryRestrictions || "", experienceLevel: prevData.experienceLevel || "", shirtSize: prevData.shirtSize || "", volunteerType: parsePreviousArrayField("volunteerType"), @@ -1910,6 +1913,21 @@ const VolunteerApplicationComponent = () => { )} + {/* Meals are served at the venue — only in-person volunteers need this */} + {!isVirtualEvent() && formData.inPerson === "Yes" && ( + + setFormData((prev) => ({ + ...prev, + dietaryRestrictions: next, + })) + } + MenuProps={refinedSelectMenuProps} + sx={refinedFieldSx} + /> + )} + {/* Show blocking alert for incompatible selection */} {hasIncompatibleSelection && ( From 8c6eaa476bda36369d1283d290573a6a3e750d04 Mon Sep 17 00:00:00 2001 From: Greg V <6913307+gregv@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:50:55 -0700 Subject: [PATCH 06/19] Docs: DietaryRestrictionsSelect contract in CLAUDE.md Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 68dd2df3..9ff6f757 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -323,6 +323,7 @@ Shared scaffolding lives in `src/components/ApplicationForm/`. Use these instead - `OHackParticipationSelect` — the "How many Opportunity Hack hackathons have you attended?" dropdown. Helper text makes clear it's about OHack only, not other hackathons. - `ProfileAutofillNotice` — reusable green "auto-filled from your profile" alert. - `MealMenu` — restaurant-style meal selector for `eventData.constraints.meals`. +- `DietaryRestrictionsSelect` — dropdown (multi-select + exclusive "None" + "Other" detail) for `formData.dietaryRestrictions` on ALL FOUR forms. Stored as a human-readable comma-joined string (legacy free-text parses back in; no backend change). Only rendered for people who'll eat on site: hacker gates on `!eventData?.isOnlineEvent`, mentor/judge/volunteer on `!isVirtualEvent() && inPerson === "Yes!"/"Yes"`. Don't revert to free text. Shown in admin `ApplicationReviewCard` + `ApplicationEditDialog` for all four types. Parse/serialize helpers are exported and unit-tested (`__tests__/DietaryRestrictionsSelect.test.js`). Primary copy on these forms uses `body1`. Reserve `body2` for true helper text under inputs. **Adding a new application field needs NO backend change.** Submissions POST to `/api/{type}/application//{submit,update}` → `handle_submit` → `create_or_update_volunteer` (`services/volunteers_service.py`), which persists the **entire** `volunteer_data` dict (`volunteer_doc.update(volunteer_data)` on create, `set(merge=True)` on update) — there is no field allowlist for volunteer/mentor/judge/hacker apps (unlike `save_hackathon`). New form fields flow through and are stored as-is. Mirror the `expertise`/`softwareEngineeringSpecifics` pattern: keep multi-selects as arrays in form state, join to a comma-string at submit (swapping an "Other" option for its free-text value), and split back on `loadPreviousSubmission`. To make a new field visible in admin review, add it to the type's `secondaryFields`/`additionalFields` + `labelMap` in `src/components/admin/ApplicationReviewCard.js` (empty values are auto-skipped, so legacy rows stay clean). **Mentor form** captures AI-tool usage (`aiTools[]`+`otherAiTools` → joined `aiToolsUsed`, plus `aiToolsExperience`) in Step 2. From 62799525d83d82dd1336620e5a24ccac011c7393 Mon Sep 17 00:00:00 2001 From: Greg V <6913307+gregv@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:46:29 -0700 Subject: [PATCH 07/19] =?UTF-8?q?Meals:=20times-only=20mode=20=E2=80=94=20?= =?UTF-8?q?publish=20meal=20times=20without=20hacker=20item=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds constraints.meals_mode ("menu" default | "schedule") and an optional constraints.meals_note. In schedule mode the admin Meals section becomes a minimal name+time editor (items/catalog/cost UI hidden, item data kept) and the hacker application renders the new read-only MealSchedule instead of the MealMenu pickers. formatMealTime now renders admin-picked ISO times human-readably in both components. Unit tests for the helpers, the schedule component, and the hacker-form gating. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 5 +- .../Hacker/LocationDemographicsStep.js | 18 +- .../LocationDemographicsStep.test.js | 75 ++++++++ src/components/ApplicationForm/MealMenu.js | 3 +- .../ApplicationForm/MealSchedule.js | 112 +++++++++++ .../__tests__/MealSchedule.test.js | 108 +++++++++++ src/components/ApplicationForm/index.js | 8 + .../hackathon-edit/sections/MealsSection.js | 181 +++++++++++++----- 8 files changed, 460 insertions(+), 50 deletions(-) create mode 100644 src/components/ApplicationForm/Hacker/__tests__/LocationDemographicsStep.test.js create mode 100644 src/components/ApplicationForm/MealSchedule.js create mode 100644 src/components/ApplicationForm/__tests__/MealSchedule.test.js diff --git a/CLAUDE.md b/CLAUDE.md index 7d663dc9..524c0b67 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -426,8 +426,9 @@ The `constraints` object on a hackathon doc carries per-event toggles. Keys cons - `judge_venue_arrival_time` (HH:MM, 24-hour) — judge form's Availability step shows it when set; falls back to existing default copy when null. - `judge_judging_start_time` / `judge_judging_end_time` (HH:MM, 24-hour) — the final-day judging window. Drives ALL judging-window copy on the judge form (schedule alert, "For this event" panel, commitment question, and the physical-event blocking validation message) via `getJudgingWindow(eventData)`; defaults to 15:00/17:30 when unset. Admin UI in `hackathon-edit/sections/JudgesSection.js` (next to arrival time); backend keys validated via `JUDGE_TIME_CONSTRAINT_KEYS` in `validators.py`. Times display through `formatTime12h` (module-scope in `judge-application.js`). - `hacker_deposit: { enabled, default_amount_cents }` — when enabled, hacker form's Review step adds deposit fields and routes through Stripe Checkout (see below) before submit. -- `meals: [{ id, name, time, catering_provided, dietary_tags, items: [{ id, name, description, dietary_tags }] }]` — hacker form renders a `MealMenu` for each slot when in-person and meals are configured. Allowed `dietary_tags` are validated server-side; keep them in sync with `ALLOWED_DIETARY_TAGS` in `MealManagement.js` and the backend `validators.py`. - Admin UI lives in `src/pages/admin/hackathons/index.js` Advanced Settings tab. New `MealManagement` component handles meal editing. +- `meals: [{ id, name, time, catering_provided, dietary_tags, items: [{ id, name, description, dietary_tags }] }]` — hacker form renders a `MealMenu` for each slot when in-person and meals are configured. Allowed `dietary_tags` are validated server-side; keep them in sync with `ALLOWED_DIETARY_TAGS` in `MealsSection.js` and the backend `validators.py`. +- `meals_mode` ("menu" default | "schedule") + `meals_note` (optional string ≤ 500) — how meals render on the hacker form. `"schedule"` = **times-only**: the hacker form swaps `MealMenu` for the read-only `MealSchedule` (`src/components/ApplicationForm/MealSchedule.js` — also exports `getMealsMode`/`formatMealTime`/`MEALS_MODE_*`/`MEALS_NOTE_MAX_LENGTH`, all unit-tested in `__tests__/MealSchedule.test.js`; `formatMealTime` renders admin-picked ISO times human-readably in BOTH components); `meals_note` shows above the schedule. Admin `MealsSection` has a "What hackers see" toggle: schedule mode hides the items editor/catalog/costs/headcount (items data is KEPT, not deleted), shows the note field, previews via the real `MealSchedule`, and quick-add creates slots with `items: []` (a blank item would fail backend `validate_meals`). Anything except explicit `"schedule"` resolves to `"menu"` (legacy docs unchanged). Backend: `ALLOWED_MEALS_MODES`/`MAX_MEALS_NOTE_LENGTH` in `validators.py` (both hackathon validators) — keep in sync with the frontend constants. + Meal editing lives in `hackathon-edit/sections/MealsSection.js` (`/admin/hackathons/[event_id]?section=meals`) — `MealManagement` was deleted (see "Removed legacy components"). ## Hacker Stripe Deposit Flow diff --git a/src/components/ApplicationForm/Hacker/LocationDemographicsStep.js b/src/components/ApplicationForm/Hacker/LocationDemographicsStep.js index 962ff9fc..4dbccc55 100644 --- a/src/components/ApplicationForm/Hacker/LocationDemographicsStep.js +++ b/src/components/ApplicationForm/Hacker/LocationDemographicsStep.js @@ -13,7 +13,13 @@ import { TextField, Typography, } from "@mui/material"; -import { DietaryRestrictionsSelect, MealMenu } from "../index"; +import { + DietaryRestrictionsSelect, + MealMenu, + MealSchedule, + MEALS_MODE_SCHEDULE, + getMealsMode, +} from "../index"; import { AGE_RANGE_OPTIONS, ARIZONA_COUNTY_OPTIONS, @@ -242,7 +248,13 @@ const LocationDemographicsStep = ({ {!eventData?.isOnlineEvent && Array.isArray(eventData?.constraints?.meals) && - eventData.constraints.meals.length > 0 && ( + eventData.constraints.meals.length > 0 && + (getMealsMode(eventData.constraints) === MEALS_MODE_SCHEDULE ? ( + + ) : ( ({ ...prev, mealSelections: next })) } /> - )} + ))} ); diff --git a/src/components/ApplicationForm/Hacker/__tests__/LocationDemographicsStep.test.js b/src/components/ApplicationForm/Hacker/__tests__/LocationDemographicsStep.test.js new file mode 100644 index 00000000..1662d278 --- /dev/null +++ b/src/components/ApplicationForm/Hacker/__tests__/LocationDemographicsStep.test.js @@ -0,0 +1,75 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import LocationDemographicsStep from "../LocationDemographicsStep"; + +const MEALS = [ + { id: "m1", name: "Saturday Lunch", time: "2026-10-10T12:00:00", items: [] }, + { + id: "m2", + name: "Saturday Dinner", + time: "2026-10-10T18:00:00", + items: [ + { id: "i1", name: "Veggie bowl" }, + { id: "i2", name: "Chicken bowl" }, + ], + }, +]; + +const renderStep = (eventData) => + render( + , + ); + +describe("LocationDemographicsStep meals rendering", () => { + it("renders the item picker by default when meals are configured", () => { + renderStep({ + isOnlineEvent: false, + constraints: { meals: MEALS }, + }); + expect(screen.getByText("Meal selections")).toBeInTheDocument(); + expect(screen.queryByText("Meal schedule")).not.toBeInTheDocument(); + expect(screen.getAllByRole("radio").length).toBeGreaterThan(0); + }); + + it("renders the read-only schedule when meals_mode is 'schedule'", () => { + renderStep({ + isOnlineEvent: false, + constraints: { + meals: MEALS, + meals_mode: "schedule", + meals_note: "Breakfast, lunch, and dinner are on us.", + }, + }); + expect(screen.getByText("Meal schedule")).toBeInTheDocument(); + expect( + screen.getByText("Breakfast, lunch, and dinner are on us."), + ).toBeInTheDocument(); + expect(screen.queryByText("Meal selections")).not.toBeInTheDocument(); + expect(screen.queryByRole("radio")).not.toBeInTheDocument(); + // Menu items never render in schedule mode + expect(screen.queryByText("Veggie bowl")).not.toBeInTheDocument(); + }); + + it("renders no meal section for online events regardless of mode", () => { + renderStep({ + isOnlineEvent: true, + constraints: { meals: MEALS, meals_mode: "schedule" }, + }); + expect(screen.queryByText("Meal schedule")).not.toBeInTheDocument(); + expect(screen.queryByText("Meal selections")).not.toBeInTheDocument(); + }); + + it("renders no meal section when no meals are configured", () => { + renderStep({ + isOnlineEvent: false, + constraints: { meals_mode: "schedule" }, + }); + expect(screen.queryByText("Meal schedule")).not.toBeInTheDocument(); + expect(screen.queryByText("Meal selections")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/ApplicationForm/MealMenu.js b/src/components/ApplicationForm/MealMenu.js index 17887e74..ea3d137c 100644 --- a/src/components/ApplicationForm/MealMenu.js +++ b/src/components/ApplicationForm/MealMenu.js @@ -10,6 +10,7 @@ import { RadioGroup, Typography, } from "@mui/material"; +import { formatMealTime } from "./MealSchedule"; const MealMenu = ({ meals = [], selections = {}, onChange }) => { if (!meals || meals.length === 0) return null; @@ -48,7 +49,7 @@ const MealMenu = ({ meals = [], selections = {}, onChange }) => { {meal.time && ( - {meal.time} + {formatMealTime(meal.time)} )} diff --git a/src/components/ApplicationForm/MealSchedule.js b/src/components/ApplicationForm/MealSchedule.js new file mode 100644 index 00000000..0f124ba5 --- /dev/null +++ b/src/components/ApplicationForm/MealSchedule.js @@ -0,0 +1,112 @@ +import React from "react"; +import { Box, Chip, Paper, Typography } from "@mui/material"; +import { format, parseISO } from "date-fns"; + +// How an event collects meals from hackers. Stored on the hackathon doc as +// `constraints.meals_mode`. Kept in sync with backend ALLOWED_MEALS_MODES in +// validators.py. +export const MEALS_MODE_MENU = "menu"; // hackers pick one item per slot +export const MEALS_MODE_SCHEDULE = "schedule"; // times-only, nothing to pick + +// Max length for the optional `constraints.meals_note` intro line. Kept in +// sync with MAX_MEALS_NOTE_LENGTH in backend validators.py. +export const MEALS_NOTE_MAX_LENGTH = 500; + +// Resolve the meals mode from a hackathon `constraints` object. Anything that +// isn't explicitly "schedule" (legacy docs, unknown values, missing key) +// resolves to the original full-menu behavior. +export const getMealsMode = (constraints) => + constraints?.meals_mode === MEALS_MODE_SCHEDULE + ? MEALS_MODE_SCHEDULE + : MEALS_MODE_MENU; + +// Meal times are ISO strings when set through the admin DateTimePicker, but +// legacy meals carry free text ("Saturday around noon"). Format ISO nicely +// and pass free text through untouched. +export const formatMealTime = (value) => { + if (!value) return ""; + try { + const d = parseISO(value); + if (!isNaN(d.getTime())) return format(d, "EEE MMM d, h:mm a"); + } catch { + // fall through to the raw string + } + return value; +}; + +// Read-only meal schedule for events that publish meal times without menus +// (constraints.meals_mode === "schedule"). Mirrors MealMenu's card layout but +// asks nothing of the hacker. +const MealSchedule = ({ meals = [], note = "" }) => { + if (!meals || meals.length === 0) return null; + + return ( + + + Meal schedule + + + Meals are served at the times below — there's nothing to pre-select. + + {note && ( + + {note} + + )} + + {meals.map((meal) => ( + + + + {meal.name} + + {meal.time && ( + + {formatMealTime(meal.time)} + + )} + + + {Array.isArray(meal.dietary_tags) && meal.dietary_tags.length > 0 && ( + + {meal.dietary_tags.map((t) => ( + + ))} + + )} + + {meal.catering_provided === false && ( + + Catering isn't provided for this meal — please plan to bring or + buy your own. + + )} + + ))} + + ); +}; + +export default MealSchedule; diff --git a/src/components/ApplicationForm/__tests__/MealSchedule.test.js b/src/components/ApplicationForm/__tests__/MealSchedule.test.js new file mode 100644 index 00000000..7717062d --- /dev/null +++ b/src/components/ApplicationForm/__tests__/MealSchedule.test.js @@ -0,0 +1,108 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import MealSchedule, { + MEALS_MODE_MENU, + MEALS_MODE_SCHEDULE, + getMealsMode, + formatMealTime, +} from "../MealSchedule"; + +describe("getMealsMode", () => { + it("defaults to menu mode for missing or empty constraints", () => { + expect(getMealsMode(undefined)).toBe(MEALS_MODE_MENU); + expect(getMealsMode(null)).toBe(MEALS_MODE_MENU); + expect(getMealsMode({})).toBe(MEALS_MODE_MENU); + expect(getMealsMode({ meals_mode: null })).toBe(MEALS_MODE_MENU); + expect(getMealsMode({ meals_mode: "" })).toBe(MEALS_MODE_MENU); + }); + + it("treats unknown values as menu mode", () => { + expect(getMealsMode({ meals_mode: "buffet" })).toBe(MEALS_MODE_MENU); + expect(getMealsMode({ meals_mode: 42 })).toBe(MEALS_MODE_MENU); + }); + + it("returns schedule mode only when explicitly set", () => { + expect(getMealsMode({ meals_mode: "schedule" })).toBe(MEALS_MODE_SCHEDULE); + expect(getMealsMode({ meals_mode: "menu" })).toBe(MEALS_MODE_MENU); + }); +}); + +describe("formatMealTime", () => { + it("returns empty string for falsy values", () => { + expect(formatMealTime("")).toBe(""); + expect(formatMealTime(null)).toBe(""); + expect(formatMealTime(undefined)).toBe(""); + }); + + it("formats ISO datetimes for humans", () => { + // Local ISO (no zone suffix) keeps the assertion timezone-independent. + expect(formatMealTime("2026-10-10T19:00:00")).toBe("Sat Oct 10, 7:00 PM"); + }); + + it("passes legacy free-text times through untouched", () => { + expect(formatMealTime("Saturday around noon")).toBe( + "Saturday around noon", + ); + }); +}); + +describe("MealSchedule", () => { + const meals = [ + { + id: "m1", + name: "Saturday Lunch", + time: "2026-10-10T12:00:00", + dietary_tags: ["vegetarian"], + }, + { + id: "m2", + name: "Saturday Dinner", + time: "Saturday evening", + catering_provided: false, + }, + ]; + + it("renders nothing when there are no meals", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("shows each meal with a human-readable time and no inputs", () => { + render(); + expect(screen.getByText("Meal schedule")).toBeInTheDocument(); + expect(screen.getByText("Saturday Lunch")).toBeInTheDocument(); + expect(screen.getByText("Sat Oct 10, 12:00 PM")).toBeInTheDocument(); + expect(screen.getByText("Saturday evening")).toBeInTheDocument(); + // Read-only: nothing for the hacker to select + expect(screen.queryByRole("radio")).not.toBeInTheDocument(); + expect(screen.queryByRole("checkbox")).not.toBeInTheDocument(); + }); + + it("renders slot dietary tags", () => { + render(); + expect( + screen.getByText("✓ vegetarian options available"), + ).toBeInTheDocument(); + }); + + it("flags meals without catering", () => { + render(); + expect( + screen.getByText(/Catering isn't provided for this meal/), + ).toBeInTheDocument(); + }); + + it("shows the optional event note above the schedule", () => { + render( + , + ); + expect( + screen.getByText("Vegan options at every meal."), + ).toBeInTheDocument(); + }); + + it("omits the note paragraph when no note is set", () => { + render(); + expect(screen.queryByText(/every meal\./)).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/ApplicationForm/index.js b/src/components/ApplicationForm/index.js index 21bd0bb2..e868cbab 100644 --- a/src/components/ApplicationForm/index.js +++ b/src/components/ApplicationForm/index.js @@ -5,6 +5,14 @@ export { default as ProfileAutofillNotice } from "./ProfileAutofillNotice"; export { default as PronounsPicker } from "./PronounsPicker"; export { CURATED_PRONOUNS } from "./PronounsPicker"; export { default as MealMenu } from "./MealMenu"; +export { default as MealSchedule } from "./MealSchedule"; +export { + MEALS_MODE_MENU, + MEALS_MODE_SCHEDULE, + MEALS_NOTE_MAX_LENGTH, + getMealsMode, + formatMealTime, +} from "./MealSchedule"; export { default as DietaryRestrictionsSelect } from "./DietaryRestrictionsSelect"; export { DIETARY_RESTRICTION_OPTIONS, diff --git a/src/components/admin/hackathon-edit/sections/MealsSection.js b/src/components/admin/hackathon-edit/sections/MealsSection.js index 800ca04a..91e42e3f 100644 --- a/src/components/admin/hackathon-edit/sections/MealsSection.js +++ b/src/components/admin/hackathon-edit/sections/MealsSection.js @@ -46,6 +46,12 @@ import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns"; import { format, parseISO } from "date-fns"; import SectionContainer from "../SectionContainer"; import MenuCatalogPicker from "../catalog/MenuCatalogPicker"; +import MealSchedule, { + MEALS_MODE_MENU, + MEALS_MODE_SCHEDULE, + MEALS_NOTE_MAX_LENGTH, + getMealsMode, +} from "../../../ApplicationForm/MealSchedule"; import { computeAllMealsCostCents, computeItemCostCents, @@ -142,7 +148,7 @@ const ItemCostRow = ({ item, headcount }) => { ); }; -const MealEditor = ({ meal, mealIndex, onUpdate, onRemove, onClone, onOpenCatalog, dragHandleProps, eventStart, eventEnd, headcount }) => { +const MealEditor = ({ meal, mealIndex, onUpdate, onRemove, onClone, onOpenCatalog, dragHandleProps, eventStart, eventEnd, headcount, scheduleOnly = false }) => { 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)); @@ -169,7 +175,7 @@ const MealEditor = ({ meal, mealIndex, onUpdate, onRemove, onClone, onOpenCatalo {meal.name || `Meal ${mealIndex + 1}`} - {mealCost > 0 && ( + {!scheduleOnly && mealCost > 0 && ( - + - + - - 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"} - /> - + {!scheduleOnly && ( + + 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 && ( @@ -266,7 +274,15 @@ const MealEditor = ({ meal, mealIndex, onUpdate, onRemove, onClone, onOpenCatalo - {meal.catering_provided !== false && ( + {scheduleOnly && (meal.items || []).length > 0 && ( + + {(meal.items || []).length} menu item{(meal.items || []).length === 1 ? "" : "s"} hidden + while "Times only" is on — the items are kept and come back if you + switch to full menus. + + )} + + {!scheduleOnly && meal.catering_provided !== false && ( Menu options @@ -381,14 +397,23 @@ const MealEditor = ({ meal, mealIndex, onUpdate, onRemove, onClone, onOpenCatalo ); }; -const HackerPreview = ({ meals }) => { +const HackerPreview = ({ meals, mealsMode = MEALS_MODE_MENU, note = "" }) => { if (!meals || meals.length === 0) { return ( - Hackers won't see a meal selector until you add at least one slot. + Hackers won't see{" "} + {mealsMode === MEALS_MODE_SCHEDULE + ? "the meal schedule" + : "a meal selector"}{" "} + until you add at least one slot. ); } + // Times-only mode renders the exact component the hacker application uses, + // so the preview can't drift from reality. + if (mealsMode === MEALS_MODE_SCHEDULE) { + return ; + } return ( {meals.map((meal) => ( @@ -496,6 +521,9 @@ 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 mealsMode = getMealsMode(hackathon.constraints); + const scheduleOnly = mealsMode === MEALS_MODE_SCHEDULE; + const mealsNote = hackathon.constraints?.meals_note || ""; const dirty = dirtySections.has("meals"); const saving = saveState.status === "saving"; const [showPreview, setShowPreview] = useState(true); @@ -519,8 +547,26 @@ const MealsSection = ({ admin }) => { const updateOne = (index, value) => updateMeals(meals.map((m, i) => (i === index ? value : m))); const removeOne = (index) => updateMeals(meals.filter((_, i) => i !== index)); + // Times-only slots start with no items — a leftover blank item would fail + // the backend's validate_meals (items need a non-empty name) even though + // the items editor is hidden in that mode. const addNew = (presetName) => - updateMeals([...meals, blankMeal(presetName ? { name: presetName } : {})]); + updateMeals([ + ...meals, + blankMeal({ + ...(presetName ? { name: presetName } : {}), + ...(scheduleOnly ? { items: [] } : {}), + }), + ]); + + const setMealsMode = (mode) => { + setConstraint("meals_mode", mode); + markSectionDirty("meals", true); + }; + const setMealsNote = (note) => { + setConstraint("meals_note", note); + markSectionDirty("meals", true); + }; const cloneOne = (index) => { const src = meals[index]; const dup = { @@ -565,7 +611,11 @@ const MealsSection = ({ admin }) => { return ( { onDiscard={() => discardSection("meals")} > - + + + What hackers see + + v && setMealsMode(v)} + > + + Full menus — hackers pick items + + + Times only — just show the schedule + + + + {scheduleOnly + ? "The hacker application shows meal names and times only — no item selection." + : "The hacker application asks each hacker to pick one item per meal slot."} + + + {scheduleOnly ? ( 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 }} + fullWidth + multiline + minRows={2} + value={mealsNote} + onChange={(e) => setMealsNote(e.target.value)} + inputProps={{ maxLength: MEALS_NOTE_MAX_LENGTH }} + placeholder="We'll provide breakfast, lunch, and dinner — vegetarian and vegan options at every meal." + helperText={`Shown above the meal schedule on the hacker application (${mealsNote.length}/${MEALS_NOTE_MAX_LENGTH})`} /> - - - + ) : ( + <> + + 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 }} + /> + + + + + )} @@ -636,6 +726,7 @@ const MealsSection = ({ admin }) => { eventStart={eventStart} eventEnd={eventEnd} headcount={headcount} + scheduleOnly={scheduleOnly} /> )} @@ -674,9 +765,11 @@ const MealsSection = ({ admin }) => { Hacker preview - Roughly how the meal selector renders on the hacker application. + {scheduleOnly + ? "Exactly how the meal schedule renders on the hacker application." + : "Roughly how the meal selector renders on the hacker application."} - + )} From 200e95388fcf77b53222efce38f3670e2cbd2173 Mon Sep 17 00:00:00 2001 From: Greg V <6913307+gregv@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:12:36 -0700 Subject: [PATCH 08/19] Meals: show the meal schedule to in-person mentors, judges, and volunteers The read-only MealSchedule now renders on the mentor, judge, and volunteer applications directly above DietaryRestrictionsSelect, under the same gate that PR #349 established for dietary restrictions (!isVirtualEvent() and an in-person "Yes!"/"Yes" answer). These roles never pick menu items, so the schedule shows regardless of meals_mode. Mentor + volunteer setEventData now retain constraints (judge already did); the component owns the no-meals case. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- .../ApplicationForm/__tests__/MealSchedule.test.js | 7 +++++++ src/pages/hack/[event_id]/judge-application.js | 10 +++++++++- src/pages/hack/[event_id]/mentor-application.js | 11 ++++++++++- src/pages/hack/[event_id]/volunteer-application.js | 12 +++++++++++- 5 files changed, 38 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 524c0b67..a1a0dc1c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -427,7 +427,7 @@ The `constraints` object on a hackathon doc carries per-event toggles. Keys cons - `judge_judging_start_time` / `judge_judging_end_time` (HH:MM, 24-hour) — the final-day judging window. Drives ALL judging-window copy on the judge form (schedule alert, "For this event" panel, commitment question, and the physical-event blocking validation message) via `getJudgingWindow(eventData)`; defaults to 15:00/17:30 when unset. Admin UI in `hackathon-edit/sections/JudgesSection.js` (next to arrival time); backend keys validated via `JUDGE_TIME_CONSTRAINT_KEYS` in `validators.py`. Times display through `formatTime12h` (module-scope in `judge-application.js`). - `hacker_deposit: { enabled, default_amount_cents }` — when enabled, hacker form's Review step adds deposit fields and routes through Stripe Checkout (see below) before submit. - `meals: [{ id, name, time, catering_provided, dietary_tags, items: [{ id, name, description, dietary_tags }] }]` — hacker form renders a `MealMenu` for each slot when in-person and meals are configured. Allowed `dietary_tags` are validated server-side; keep them in sync with `ALLOWED_DIETARY_TAGS` in `MealsSection.js` and the backend `validators.py`. -- `meals_mode` ("menu" default | "schedule") + `meals_note` (optional string ≤ 500) — how meals render on the hacker form. `"schedule"` = **times-only**: the hacker form swaps `MealMenu` for the read-only `MealSchedule` (`src/components/ApplicationForm/MealSchedule.js` — also exports `getMealsMode`/`formatMealTime`/`MEALS_MODE_*`/`MEALS_NOTE_MAX_LENGTH`, all unit-tested in `__tests__/MealSchedule.test.js`; `formatMealTime` renders admin-picked ISO times human-readably in BOTH components); `meals_note` shows above the schedule. Admin `MealsSection` has a "What hackers see" toggle: schedule mode hides the items editor/catalog/costs/headcount (items data is KEPT, not deleted), shows the note field, previews via the real `MealSchedule`, and quick-add creates slots with `items: []` (a blank item would fail backend `validate_meals`). Anything except explicit `"schedule"` resolves to `"menu"` (legacy docs unchanged). Backend: `ALLOWED_MEALS_MODES`/`MAX_MEALS_NOTE_LENGTH` in `validators.py` (both hackathon validators) — keep in sync with the frontend constants. +- `meals_mode` ("menu" default | "schedule") + `meals_note` (optional string ≤ 500) — how meals render on the hacker form. `"schedule"` = **times-only**: the hacker form swaps `MealMenu` for the read-only `MealSchedule` (`src/components/ApplicationForm/MealSchedule.js` — also exports `getMealsMode`/`formatMealTime`/`MEALS_MODE_*`/`MEALS_NOTE_MAX_LENGTH`, all unit-tested in `__tests__/MealSchedule.test.js`; `formatMealTime` renders admin-picked ISO times human-readably in BOTH components); `meals_note` shows above the schedule. Admin `MealsSection` has a "What hackers see" toggle: schedule mode hides the items editor/catalog/costs/headcount (items data is KEPT, not deleted), shows the note field, previews via the real `MealSchedule`, and quick-add creates slots with `items: []` (a blank item would fail backend `validate_meals`). Anything except explicit `"schedule"` resolves to `"menu"` (legacy docs unchanged). Backend: `ALLOWED_MEALS_MODES`/`MAX_MEALS_NOTE_LENGTH` in `validators.py` (both hackathon validators) — keep in sync with the frontend constants. **Mentor/judge/volunteer forms** also render the read-only `MealSchedule` (regardless of `meals_mode` — those roles never pick items) directly above their `DietaryRestrictionsSelect`, under the SAME in-person gate (`!isVirtualEvent() && inPerson === "Yes!"/"Yes"`); the component owns the no-meals case (renders null), so pages pass `constraints?.meals || []` with no length check. Mentor + volunteer `setEventData` now retain `constraints` (judge already did) — don't drop that key or the schedule silently disappears. Meal editing lives in `hackathon-edit/sections/MealsSection.js` (`/admin/hackathons/[event_id]?section=meals`) — `MealManagement` was deleted (see "Removed legacy components"). ## Hacker Stripe Deposit Flow diff --git a/src/components/ApplicationForm/__tests__/MealSchedule.test.js b/src/components/ApplicationForm/__tests__/MealSchedule.test.js index 7717062d..02aeecea 100644 --- a/src/components/ApplicationForm/__tests__/MealSchedule.test.js +++ b/src/components/ApplicationForm/__tests__/MealSchedule.test.js @@ -67,6 +67,13 @@ describe("MealSchedule", () => { expect(container).toBeEmptyDOMElement(); }); + it("renders nothing when the meals prop is missing entirely", () => { + // The mentor/judge/volunteer forms pass `constraints?.meals || []` + // straight through — the component must own the empty case. + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + it("shows each meal with a human-readable time and no inputs", () => { render(); expect(screen.getByText("Meal schedule")).toBeInTheDocument(); diff --git a/src/pages/hack/[event_id]/judge-application.js b/src/pages/hack/[event_id]/judge-application.js index c0305c67..60b11469 100644 --- a/src/pages/hack/[event_id]/judge-application.js +++ b/src/pages/hack/[event_id]/judge-application.js @@ -66,6 +66,7 @@ import { DietaryRestrictionsSelect, IntroVideoField, JudgeTrainingGate, + MealSchedule, OHackParticipationSelect, PronounsPicker, scrollToStepContent, @@ -2003,7 +2004,14 @@ const JudgeApplicationComponent = () => { )} - {/* Meals are served at the venue — only in-person judges need this */} + {/* Meals are served at the venue — only in-person judges need this. + MealSchedule renders nothing when the event has no meals configured. */} + {!isVirtualEvent() && formData.inPerson === "Yes" && ( + + )} {!isVirtualEvent() && formData.inPerson === "Yes" && ( { "https://cdn.ohack.dev/ohack.dev/2023_hackathon_2.webp", isEventPast, timezone: eventData.timezone, + constraints: eventData.constraints || {}, }); // Generate time slots based on event dates @@ -1548,7 +1550,14 @@ const MentorApplicationComponent = () => { )} - {/* Meals are provided on site — only in-person mentors need this */} + {/* Meals are provided on site — only in-person mentors need this. + MealSchedule renders nothing when the event has no meals configured. */} + {!isVirtualEvent() && formData.inPerson === "Yes!" && ( + + )} {!isVirtualEvent() && formData.inPerson === "Yes!" && ( { "https://cdn.ohack.dev/ohack.dev/2023_hackathon_2.webp", isEventPast, timezone: eventData.timezone, + constraints: eventData.constraints || {}, }); // Generate time slots based on event dates with actual slot counts @@ -1913,7 +1915,15 @@ const VolunteerApplicationComponent = () => { )} - {/* Meals are served at the venue — only in-person volunteers need this */} + {/* Meals are served at the venue — only in-person volunteers need + this. MealSchedule renders nothing when the event has no meals + configured. */} + {!isVirtualEvent() && formData.inPerson === "Yes" && ( + + )} {!isVirtualEvent() && formData.inPerson === "Yes" && ( Date: Thu, 20 Aug 2026 17:37:43 -0700 Subject: [PATCH 09/19] bold pls --- src/pages/hack/[event_id]/judge-application.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/pages/hack/[event_id]/judge-application.js b/src/pages/hack/[event_id]/judge-application.js index 60b11469..e1a52c82 100644 --- a/src/pages/hack/[event_id]/judge-application.js +++ b/src/pages/hack/[event_id]/judge-application.js @@ -1792,11 +1792,10 @@ const JudgeApplicationComponent = () => { Important judging schedule - Judging starts at {judgingWindow.start} on the last day of the + Judging starts at {judgingWindow.start} on the last day of the hackathon (typically Sunday). We expect to complete judging and - announce the winning teams by {judgingWindow.end}. Your presence - during this entire timeframe is crucial. Please plan to arrive 15 - to 30 minutes early to ensure you can participate fully. + announce the winning teams by {judgingWindow.end}. Your presence + during this entire timeframe is crucial. From e0025405fa5107348be62e7c6aa4a8d27d5df039 Mon Sep 17 00:00:00 2001 From: Greg V <6913307+gregv@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:41:40 -0700 Subject: [PATCH 10/19] extra wording --- src/components/ApplicationForm/MealSchedule.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ApplicationForm/MealSchedule.js b/src/components/ApplicationForm/MealSchedule.js index 0f124ba5..7f1013b1 100644 --- a/src/components/ApplicationForm/MealSchedule.js +++ b/src/components/ApplicationForm/MealSchedule.js @@ -46,7 +46,7 @@ const MealSchedule = ({ meals = [], note = "" }) => { Meal schedule - Meals are served at the times below — there's nothing to pre-select. + Meals are served at the times below {note && ( From 7150bf06d40d3c0bba5c2100ff14933413dccfc7 Mon Sep 17 00:00:00 2001 From: Greg V <6913307+gregv@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:45:44 -0700 Subject: [PATCH 11/19] judge app small changes --- src/pages/hack/[event_id]/judge-application.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/pages/hack/[event_id]/judge-application.js b/src/pages/hack/[event_id]/judge-application.js index e1a52c82..6630d087 100644 --- a/src/pages/hack/[event_id]/judge-application.js +++ b/src/pages/hack/[event_id]/judge-application.js @@ -2062,9 +2062,13 @@ const JudgeApplicationComponent = () => { /> } label={ - + I will review each project I'm assigned and ask questions tied to - the judging criteria — Scope, Documentation, Polish, and Security. + the judging criteria: Scope, Documentation, Polish, Security, and Accessibility in order to help find the best solution to help nonprofits, humans, and the world. } sx={{ mb: 2, alignItems: "flex-start", color: "var(--ink)" }} @@ -2081,7 +2085,11 @@ const JudgeApplicationComponent = () => { /> } label={ - + I agree to the{" "} Date: Thu, 20 Aug 2026 20:16:54 -0700 Subject: [PATCH 12/19] Align pending-review messaging across judge/mentor/volunteer apps Sync the "up to 14 business days, we're volunteers too" pending-review copy across the judge, mentor, and volunteer application forms (pre-submit notice + post-submit success screen), matching the wording already updated on the judge form. Co-Authored-By: Claude Sonnet 5 --- .../hack/[event_id]/judge-application.js | 20 ++++++++------ .../hack/[event_id]/mentor-application.js | 20 ++++++++------ .../hack/[event_id]/volunteer-application.js | 27 ++++++++++++++----- 3 files changed, 44 insertions(+), 23 deletions(-) diff --git a/src/pages/hack/[event_id]/judge-application.js b/src/pages/hack/[event_id]/judge-application.js index 6630d087..9786fc28 100644 --- a/src/pages/hack/[event_id]/judge-application.js +++ b/src/pages/hack/[event_id]/judge-application.js @@ -2106,10 +2106,12 @@ const JudgeApplicationComponent = () => { - Your application is pending review — our staff - reviews every judge application by hand, which can take up to a week. - We'll email you once you're approved or if we have follow-up - questions. + Upon submission, your application is{" "} + pending review — our staff reviews every judge + application by hand, which can take up to 14 business days. We'd + love to make this faster, but we all have full-time jobs and help + our community during off-hours. We'll email you once you're + approved or if we have follow-up questions. @@ -2261,10 +2263,12 @@ const JudgeApplicationComponent = () => { - Your application is pending review. Our staff - reviews every judge application — this typically takes up to a - week. You'll get an email when you're approved or if we have - follow-up questions. + Your application is pending review. Our + staff reviews every judge application by hand, which can + take up to 14 business days. We'd love to make this faster, + but we all have full-time jobs and help our community + during off-hours. You'll get an email when you're approved + or if we have follow-up questions. diff --git a/src/pages/hack/[event_id]/mentor-application.js b/src/pages/hack/[event_id]/mentor-application.js index 97d1f6d0..4fb72b8d 100644 --- a/src/pages/hack/[event_id]/mentor-application.js +++ b/src/pages/hack/[event_id]/mentor-application.js @@ -2075,10 +2075,12 @@ const MentorApplicationComponent = () => { - Your application is pending review — our staff - reviews every mentor application by hand, which can take up to a week. - We'll email you once you're approved or if we have follow-up - questions. + Upon submission, your application is{" "} + pending review — our staff reviews every mentor + application by hand, which can take up to 14 business days. We'd + love to make this faster, but we all have full-time jobs and help + our community during off-hours. We'll email you once you're + approved or if we have follow-up questions. @@ -2137,10 +2139,12 @@ const MentorApplicationComponent = () => { - Your application is pending review. Our staff - reviews every mentor application — this typically takes up to - a week. You'll get an email when you're approved or - if we have follow-up questions. + Your application is pending review. Our + staff reviews every mentor application by hand, which can + take up to 14 business days. We'd love to make this + faster, but we all have full-time jobs and help our + community during off-hours. You'll get an email when + you're approved or if we have follow-up questions. diff --git a/src/pages/hack/[event_id]/volunteer-application.js b/src/pages/hack/[event_id]/volunteer-application.js index db95ac53..5ae6e653 100644 --- a/src/pages/hack/[event_id]/volunteer-application.js +++ b/src/pages/hack/[event_id]/volunteer-application.js @@ -1837,9 +1837,12 @@ const VolunteerApplicationComponent = () => { - By submitting this form, you're expressing interest in volunteering - with Opportunity Hack. We'll review your application and contact you - with next steps soon. + By submitting this form, your application is{" "} + pending review — our staff reviews every + volunteer application by hand, which can take up to 14 business + days. We'd love to make this faster, but we all have full-time + jobs and help our community during off-hours. We'll email you + once you're approved or if we have follow-up questions. @@ -2507,11 +2510,21 @@ const VolunteerApplicationComponent = () => { - + - Thank you for applying to volunteer with Opportunity Hack. - We'll review your application and contact you with next - steps soon. + Thank you for applying to volunteer with Opportunity Hack + — we've received your application. + + + + + + Your application is pending review. Our + staff reviews every volunteer application by hand, which can + take up to 14 business days. We'd love to make this + faster, but we all have full-time jobs and help our + community during off-hours. You'll get an email when + you're approved or if we have follow-up questions. From e93f499773b7fb5ae2f507d394dc232f8c43e066 Mon Sep 17 00:00:00 2001 From: Greg V <6913307+gregv@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:26:01 -0700 Subject: [PATCH 13/19] Stop application forms from sending isSelected (approval reset bug) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every application edit was sending isSelected: false from the form's initial state (sponsor hardcoded it in the payload), which the backend used to honor — silently un-approving approved mentors, judges, volunteers, and sponsors on every update. - Remove isSelected from initialFormData in mentor/volunteer/sponsor/ hacker forms, the hardcoded payload field in sponsor, and the hydration round-trip in hacker. - Remove judge's vestigial `selected` form field and its dead hidden input (the form submits via fetch, not native POST). - Approval is now server-authoritative: the backend strips staff-owned fields from self-service submits (backend PR pairs with this). The isSelected React state used for UI gating (QR cards, feedback CTA) is a separate variable and is untouched. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- src/pages/hack/[event_id]/hacker-application.js | 5 ++--- src/pages/hack/[event_id]/judge-application.js | 4 ---- src/pages/hack/[event_id]/mentor-application.js | 3 ++- src/pages/hack/[event_id]/sponsor-application.js | 6 ++++-- src/pages/hack/[event_id]/volunteer-application.js | 3 ++- 6 files changed, 11 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a1a0dc1c..e2cb1d83 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -330,7 +330,7 @@ Shared scaffolding lives in `src/components/ApplicationForm/`. Use these instead - `DietaryRestrictionsSelect` — dropdown (multi-select + exclusive "None" + "Other" detail) for `formData.dietaryRestrictions` on ALL FOUR forms. Stored as a human-readable comma-joined string (legacy free-text parses back in; no backend change). Only rendered for people who'll eat on site: hacker gates on `!eventData?.isOnlineEvent`, mentor/judge/volunteer on `!isVirtualEvent() && inPerson === "Yes!"/"Yes"`. Don't revert to free text. Shown in admin `ApplicationReviewCard` + `ApplicationEditDialog` for all four types. Parse/serialize helpers are exported and unit-tested (`__tests__/DietaryRestrictionsSelect.test.js`). Primary copy on these forms uses `body1`. Reserve `body2` for true helper text under inputs. -**Adding a new application field needs NO backend change.** Submissions POST to `/api/{type}/application//{submit,update}` → `handle_submit` → `create_or_update_volunteer` (`services/volunteers_service.py`), which persists the **entire** `volunteer_data` dict (`volunteer_doc.update(volunteer_data)` on create, `set(merge=True)` on update) — there is no field allowlist for volunteer/mentor/judge/hacker apps (unlike `save_hackathon`). New form fields flow through and are stored as-is. Mirror the `expertise`/`softwareEngineeringSpecifics` pattern: keep multi-selects as arrays in form state, join to a comma-string at submit (swapping an "Other" option for its free-text value), and split back on `loadPreviousSubmission`. To make a new field visible in admin review, add it to the type's `secondaryFields`/`additionalFields` + `labelMap` in `src/components/admin/ApplicationReviewCard.js` (empty values are auto-skipped, so legacy rows stay clean). **Mentor form** captures AI-tool usage (`aiTools[]`+`otherAiTools` → joined `aiToolsUsed`, plus `aiToolsExperience`) in Step 2. +**Adding a new application field needs NO backend change.** Submissions POST to `/api/{type}/application//{submit,update}` → `handle_submit` → `create_or_update_volunteer` (`services/volunteers_service.py`), which persists the **entire** `volunteer_data` dict (`volunteer_doc.update(volunteer_data)` on create, `set(merge=True)` on update) — there is no field allowlist for volunteer/mentor/judge/hacker apps (unlike `save_hackathon`). New form fields flow through and are stored as-is. **The one exception is a small denylist:** `STAFF_OWNED_VOLUNTEER_FIELDS` (`services/volunteers_service.py`) is stripped from every self-service submit/update, so approval (`isSelected`), check-in (`checkInTime`/`isCheckedIn`/`checkedIn`/…) and refund bookkeeping (`deposit_status`, `deposit_refund_*`) are server-authoritative and can NOT round-trip through a form — don't add a form field with one of those names expecting it to persist. This exists because all five forms used to ship `isSelected: false` from their `initialFormData`, silently un-approving an approved applicant on every edit (Aug 2026); **never re-add `isSelected` to a form's `initialFormData` or submit payload.** Deposit *payment* fields (`stripe_payment_intent_id`, `deposit_amount_cents`, `deposit_disposition`) are deliberately NOT in the denylist — the hacker Stripe return sets those on `/update`. The submit/update routes are also `@auth.require_user` now (identity comes from the token, never a body `user_id`). Mirror the `expertise`/`softwareEngineeringSpecifics` pattern: keep multi-selects as arrays in form state, join to a comma-string at submit (swapping an "Other" option for its free-text value), and split back on `loadPreviousSubmission`. To make a new field visible in admin review, add it to the type's `secondaryFields`/`additionalFields` + `labelMap` in `src/components/admin/ApplicationReviewCard.js` (empty values are auto-skipped, so legacy rows stay clean). **Mentor form** captures AI-tool usage (`aiTools[]`+`otherAiTools` → joined `aiToolsUsed`, plus `aiToolsExperience`) in Step 2. ### Judge form — LMS training gate (Aug 2026) diff --git a/src/pages/hack/[event_id]/hacker-application.js b/src/pages/hack/[event_id]/hacker-application.js index 3cca0d00..ce5e8261 100644 --- a/src/pages/hack/[event_id]/hacker-application.js +++ b/src/pages/hack/[event_id]/hacker-application.js @@ -323,7 +323,8 @@ const HackerApplicationComponent = () => { additionalInfo: "", requiredQuestionAnswers: [], event_id: event_id || "", - isSelected: false, + // No isSelected here on purpose: approval is staff-owned and server- + // authoritative. Sending it would un-approve an approved hacker on edit. }; // Use form persistence hook @@ -821,7 +822,6 @@ const HackerApplicationComponent = () => { photoUrl: prevData.photoUrl || "", inPerson: prevData.inPerson || (prevData.isInPerson ? "Yes" : "No"), - isSelected: prevData.isSelected || false, shirtSize: prevData.shirtSize || "", participationCount: prevData.participationCount || "", county: prevData.county || "", @@ -1348,7 +1348,6 @@ const HackerApplicationComponent = () => { return () => { cancelledFlag = true; }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, [event_id]); const handleSubmit = async (e) => { diff --git a/src/pages/hack/[event_id]/judge-application.js b/src/pages/hack/[event_id]/judge-application.js index 9786fc28..48e26ce5 100644 --- a/src/pages/hack/[event_id]/judge-application.js +++ b/src/pages/hack/[event_id]/judge-application.js @@ -187,7 +187,6 @@ const JudgeApplicationComponent = () => { () => ({ timestamp: new Date().toISOString(), email: "", - selected: false, name: "", title: "", biography: "", @@ -2114,9 +2113,6 @@ const JudgeApplicationComponent = () => { approved or if we have follow-up questions. - - {/* Selected field is not shown to users but stored in state */} - ); diff --git a/src/pages/hack/[event_id]/mentor-application.js b/src/pages/hack/[event_id]/mentor-application.js index 4fb72b8d..911e00eb 100644 --- a/src/pages/hack/[event_id]/mentor-application.js +++ b/src/pages/hack/[event_id]/mentor-application.js @@ -195,7 +195,8 @@ const MentorApplicationComponent = () => { shortBio: "", photoUrl: "", event_id: "", - isSelected: false, + // No isSelected here on purpose: approval is staff-owned and server- + // authoritative. Sending it would un-approve an approved mentor on edit. }; // Use form persistence hook diff --git a/src/pages/hack/[event_id]/sponsor-application.js b/src/pages/hack/[event_id]/sponsor-application.js index 6b0c4636..57145592 100644 --- a/src/pages/hack/[event_id]/sponsor-application.js +++ b/src/pages/hack/[event_id]/sponsor-application.js @@ -232,7 +232,8 @@ const SponsorApplicationComponent = () => { howHeard: "", additionalNotes: "", event_id: event_id || "", - isSelected: false, + // No isSelected here on purpose: approval is staff-owned and server- + // authoritative. Sending it would un-approve an approved sponsor on edit. }; // Form navigation state @@ -731,7 +732,8 @@ const SponsorApplicationComponent = () => { "", // Map logo to photoUrl for consistency type: "sponsors", volunteer_type: "sponsor", - isSelected: false, + // Don't send isSelected — the backend keeps its own value so an + // update never un-approves an already-approved sponsor. logoUrl: uploadedLogoUrlRef.current || formData.logoUrl || diff --git a/src/pages/hack/[event_id]/volunteer-application.js b/src/pages/hack/[event_id]/volunteer-application.js index 5ae6e653..13c68d5e 100644 --- a/src/pages/hack/[event_id]/volunteer-application.js +++ b/src/pages/hack/[event_id]/volunteer-application.js @@ -193,7 +193,8 @@ const VolunteerApplicationComponent = () => { codeOfConduct: false, additionalInfo: "", event_id: event_id || "", - isSelected: false, + // No isSelected here on purpose: approval is staff-owned and server- + // authoritative. Sending it would un-approve an approved volunteer on edit. photoUrl: "", // Add field for photo URL availableDays: [], // Add field for available days/time slots }; From e07dc60cee1e2de2dd6eec0f3b3d33eee852a1b1 Mon Sep 17 00:00:00 2001 From: Greg V <6913307+gregv@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:51:29 -0700 Subject: [PATCH 14/19] Add volunteer job board: /jobs pages, application form, and admin Public /jobs index and /jobs/[slug] detail pages (ISR, refined design, JobPosting schema with employmentType VOLUNTEER for Google for Jobs). Login-gated 4-step application form with required work sample, PDF resume upload, and intro video. /admin/jobs manages listings and reviews applications with one-click accept/kind-rejection emails. Sitemaps, admin nav registration, and a /volunteer cross-link included. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 10 + next-sitemap.config.js | 4 +- src/components/Jobs/JobApplicationForm.js | 865 +++++++++++++++++++ src/components/Jobs/ResumeUploadField.js | 229 +++++ src/components/Jobs/ShareRow.js | 74 ++ src/components/admin/AdminNavigation.js | 6 + src/components/admin/jobs/ApplicationsTab.js | 585 +++++++++++++ src/components/admin/jobs/ListingsTab.js | 537 ++++++++++++ src/pages/admin/index.js | 7 + src/pages/admin/jobs/index.js | 118 +++ src/pages/jobs/[slug].js | 374 ++++++++ src/pages/jobs/index.js | 439 ++++++++++ src/pages/server-sitemap.xml.js | 11 + src/pages/volunteer/index.js | 14 + 14 files changed, 3272 insertions(+), 1 deletion(-) create mode 100644 src/components/Jobs/JobApplicationForm.js create mode 100644 src/components/Jobs/ResumeUploadField.js create mode 100644 src/components/Jobs/ShareRow.js create mode 100644 src/components/admin/jobs/ApplicationsTab.js create mode 100644 src/components/admin/jobs/ListingsTab.js create mode 100644 src/pages/admin/jobs/index.js create mode 100644 src/pages/jobs/[slug].js create mode 100644 src/pages/jobs/index.js diff --git a/CLAUDE.md b/CLAUDE.md index e2cb1d83..9f8c4ced 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -787,6 +787,16 @@ Post/live-event feedback for selected volunteers + nonprofit partners. Both rout - **Auth/CAPTCHA**: not gated by `RequiredAuthProvider` — nonprofits/anonymous can submit. Logged-in `isSelected` volunteers are trusted (no CAPTCHA); everyone else gets an invisible reCAPTCHA v3 token (`useRecaptcha`). Mode `upcoming` shows a "not started yet" card; `live`→"how's it going", `post`→"how was your experience". Pages are `noindex`. Backend computes mode (timezone-aware) — the frontend does NOT recompute dates. - **Discoverability**: `src/components/Survey/SurveyCTA.js` — a self-contained CTA (var-fallback inline styles so it works in or out of `RefinedRoot`; mount-gated to avoid SSR/ISR hydration mismatch) linking to `/hack//survey`, rendered only once the event has **started** (live or ended; hidden for upcoming). Wired into: the event page `/hack/[event_id]` (after the masthead — the only surface reaching anonymous nonprofits), `mentor-checkin`, `manageteam`, `team/[team_id]`, and `judge-application` (gated on `isSelected`). Pass `eventId` + `startDate`/`endDate`/`timezone`; the component owns the visibility gate. +## Volunteer Job Board (`/jobs`, `/jobs/[slug]`, `/admin/jobs` — Aug 2026) + +Volunteer organizer roles (Social Media Manager, Hackathon Operations Lead — Phoenix, Mentor Program Lead) with an AI-resistant application: required intro **video** (reuses `IntroVideoField` — its bio-video upload mint works as-is because applying requires login), required **PDF resume** (`src/components/Jobs/ResumeUploadField.js` → `POST /api/jobs/apply/resume-upload-url`, clone of the bio-video signed-URL flow, lands at `job_applications//` on the public CDN — accepted obscured-URL risk), a role-specific **work sample** (min 200 chars, mirrored in backend `MIN_JOB_WORK_SAMPLE_LENGTH`), and a **reply-within-5-days responsiveness test** baked into the confirmation email. Load-bearing details: + +- **Backend**: blueprint `api/jobs/` (`jobs_views.py` + `jobs_service.py`; registered in `api/__init__.py`). Collections: `job_listings` (doc id = slug, **slug immutable after create**) and `job_applications` (uuid4). Public `GET /api/jobs` returns published+closed (lean fields); `GET /api/jobs/` 404s drafts/hidden but **returns closed** so shared links render a calm closed panel. Apply/`me` routes are `@auth.require_user`; submit verifies recaptcha (volunteers_service `verify_recaptcha` + FLASK_ENV=development bypass), re-verifies resume/video URLs against the caller's own CDN prefix via `get_blob_metadata`, and 409s duplicates (listing_slug+user_id). Validators in `common/utils/validators.py` (`validate_job_listing[_partial]`, `validate_job_application`, `ALLOWED_JOB_*`). Emails (Resend, `_notifications_disabled()` gate): applicant confirmation (`reply_to` questions@ohack.org + the reply-to-confirm ask), FYI to questions@ohack.org, and warm accept/reject decision emails (`POST /api/jobs/admin/applications//decision`, optional `personal_note`). TTL caches (300s) cleared on every admin write. Seed: `scripts/seed_job_listings.py` (dry-run default, skips existing slugs, seeds drafts). +- **Frontend pages**: both ISR revalidate 300. `/jobs` index is a refined pillar page (FAQ_ITEMS module-scope → `
` + FAQPage JSON-LD); its getStaticProps treats a 404 from `/api/jobs` as empty (deploy-ordering: backend must ship first or the page renders the empty state) but **rethrows other errors** (ISR keeps last good). `/jobs/[slug]` SSRs the listing publicly (SEO/unfurls) with a **top-level `JobPosting` JSON-LD node** (`employmentType: "VOLUNTEER"` → Google for Jobs; TELECOMMUTE + applicantLocationRequirements for remote/hybrid, Tempe `jobLocation` for phoenix_in_person; `datePosted`/`validThrough` set conditionally — **Next rejects `undefined` in props**). Only the `#apply` section is auth-gated — via `useAuthInfo` + `redirectToLoginPage` (app-level AuthProvider), NOT a page-level RequiredAuthProvider which would hide content from crawlers. +- **`JobApplicationForm`** (`src/components/Jobs/`): 4 steps, mentor-form patterns (refinedStyles imports, `useFormPersistence` localStorage autosave with `formType:"job"`/`eventId:slug` — `loadPreviousSubmission` deliberately NOT called; already-applied comes from `GET /api/jobs//applications/me` with a 6s `AbortSignal.timeout` so a slow backend can't pin the spinner). **The `
` MUST keep `noValidate`** — the required MUI Selects render hidden native inputs and browser constraint validation otherwise silently blocks submission (no submit event, no visible error; this bit us). Phoenix listing (`location_type === "phoenix_in_person"`) hard-blocks `inPersonOk !== "Yes"`; hours below `listing.min_hours_per_week` blocks with a kind redirect message. Never add `isSelected`/staff-owned fields to `initialFormData`. +- **Admin** `/admin/jobs?tab=listings|applications` (blog-admin auth pattern; registered in BOTH nav registries): `src/components/admin/jobs/ListingsTab.js` (table + edit Dialog — deliberately no blog-style editor pages; publish/hide quick toggle) and `ApplicationsTab.js` (filters, detail Dialog derived live from list state — stale-snapshot gotcha —, status/notes PATCH, one-click kind-rejection/accept decision emails). +- **SEO plumbing**: `/jobs/[slug]` in next-sitemap `exclude` + `jobs` substring in the 0.8-priority branch; jobs block in `server-sitemap.xml.js`. Cross-link card on `/volunteer` (`#roles` section). Footer deliberately untouched (CWV height contract). + ## Admin Feedback review (`/admin/feedback`) One `volunteer.admin`-gated page (`src/pages/admin/feedback/index.js`) with 3 MUI tabs over 3 distinct data sources (different scopes — don't merge them into one table). Plain MUI, standard `AdminPage` + `RequiredAuthProvider` shell. Registered in BOTH nav registries (`src/components/admin/AdminNavigation.js` + `src/pages/admin/index.js`). Only the active tab mounts (lazy fetch). Panels live in `src/components/admin/feedback/`: diff --git a/next-sitemap.config.js b/next-sitemap.config.js index 88dd6beb..3e923219 100644 --- a/next-sitemap.config.js +++ b/next-sitemap.config.js @@ -14,6 +14,7 @@ module.exports = { "/hackathon/[hackathon_id]", "/project/[project_id]", "/hack/[event_id]", + "/jobs/[slug]", // Dynamic routes covered by /server-sitemap.xml instead "https://api.test.ohack.dev/", "https://test.api.ohack.dev/", @@ -60,7 +61,8 @@ module.exports = { path.includes("recruit") || path.includes("hackathon") || path.includes("social-good") || - path.includes("nonprofits") + path.includes("nonprofits") || + path.includes("jobs") ) { priority = 0.8; changefreq = "weekly"; diff --git a/src/components/Jobs/JobApplicationForm.js b/src/components/Jobs/JobApplicationForm.js new file mode 100644 index 00000000..0b86d1e5 --- /dev/null +++ b/src/components/Jobs/JobApplicationForm.js @@ -0,0 +1,865 @@ +import React, { useEffect, useRef, useState } from "react"; +import { + Alert, + Box, + Button, + Checkbox, + CircularProgress, + FormControl, + FormControlLabel, + FormHelperText, + InputLabel, + MenuItem, + Select, + Step, + StepLabel, + Stepper, + TextField, + Typography, + useMediaQuery, + useTheme, +} from "@mui/material"; +import { ThemeProvider } from "@mui/material/styles"; +import { useAuthInfo } from "@propelauth/react"; +import ReactMarkdown from "react-markdown"; + +import { useEnv } from "../../context/env.context"; +import { trackEvent } from "../../lib/ga"; +import { useFormPersistence } from "../../hooks/use-form-persistence"; +import { useRecaptcha } from "../../hooks/use-recaptcha"; +import FormPersistenceControls from "../FormPersistenceControls"; +import { IntroVideoField, PronounsPicker, scrollToStepContent } from "../ApplicationForm"; +import { + refinedFormTheme, + refinedFieldSx, + refinedChoiceSx, + refinedSelectMenuProps, + stepTitleSx, + stepLeadSx, + eventMarkdownSx, + infoAlertSx, + warningAlertSx, + successAlertSx, + errorAlertSx, + emphasisPanelSx, + primaryButtonSx, + ghostButtonSx, + refinedStepperSx, + refinedStepperMobileSx, + formProseSx, +} from "../ApplicationForm/refinedStyles"; +import ResumeUploadField from "./ResumeUploadField"; +import ShareRow from "./ShareRow"; +import { Eyebrow } from "../design/refined"; + +// Volunteer job application form, rendered in the #apply section of +// /jobs/[slug] for logged-in users. Modeled on the mentor application +// (the canonical refined form): useFormPersistence for localStorage autosave, +// shared refinedStyles, step scroll via scrollToStepContent. The video is +// REQUIRED and one prompt references the work-sample answer — that pairing is +// the AI/low-effort filter, don't soften it. +// NOTE: loadPreviousSubmission is deliberately NOT used — the jobs API has its +// own GET /api/jobs//applications/me for the already-applied panel. + +const STEPS = ["About you", "Commitment", "Work sample", "Video & review"]; + +const HOURS_OPTIONS = [ + { label: "1–2 hours", floor: 1 }, + { label: "3–5 hours", floor: 3 }, + { label: "6–8 hours", floor: 6 }, + { label: "9+ hours", floor: 9 }, +]; + +const DURATION_OPTIONS = [ + "Through the Fall 2026 event", + "3–6 months", + "6–12 months", + "As long as I'm useful", +]; + +const CHANNEL_OPTIONS = ["Slack", "Email", "Either works"]; + +const SLACK_OPTIONS = ["Yes, I'm in the OHack Slack", "Not yet"]; + +const MIN_WORK_SAMPLE_CHARS = 200; // keep in sync with backend MIN_JOB_WORK_SAMPLE_LENGTH + +const EMAIL_RE = /^\S+@\S+\.\S+$/; + +const hoursFloor = (label) => + HOURS_OPTIONS.find((o) => o.label === label)?.floor ?? 0; + +const isLinkedInUrl = (value) => { + try { + const parsed = new URL(value); + return ( + (parsed.protocol === "https:" || parsed.protocol === "http:") && + parsed.hostname.toLowerCase().includes("linkedin.com") + ); + } catch (e) { + return false; + } +}; + +const initialFormData = { + name: "", + email: "", + pronouns: "", + phone: "", + location: "", + linkedinUrl: "", + inPersonOk: "", + visaAck: false, + hoursPerWeek: "", + durationCommitment: "", + preferredChannel: "", + slackMember: "", + referralSource: "", + workSampleAnswer: "", + whyOhack: "", + resumeUrl: "", + videoUrl: "", +}; + +export default function JobApplicationForm({ listing }) { + const { user, accessToken } = useAuthInfo(); + const { apiServerUrl } = useEnv(); + const { getRecaptchaToken } = useRecaptcha(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("sm")); + + const [activeStep, setActiveStep] = useState(0); + const [error, setError] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [success, setSuccess] = useState(false); + const [alreadyApplied, setAlreadyApplied] = useState(null); // null | {status} + const [checkingApplied, setCheckingApplied] = useState(true); + + const stepContentRef = useRef(null); + const appliedCheckRanRef = useRef(false); + const accessTokenRef = useRef(accessToken); + accessTokenRef.current = accessToken; + + const { + formData, + setFormData, + formRef, + handleFormChange, + loadFromLocalStorage, + saveToLocalStorage, + clearSavedData, + notification, + closeNotification, + } = useFormPersistence({ + formType: "job", + eventId: listing.slug, + userId: user?.userId, + initialFormData, + apiServerUrl, + accessToken, + }); + + const isPhoenixRole = listing.location_type === "phoenix_in_person"; + const minHours = listing.min_hours_per_week || 0; + + // Restore an in-progress draft, then prefill identity fields that are empty. + useEffect(() => { + loadFromLocalStorage(); + }, []); + + useEffect(() => { + if (!user) return; + setFormData((prev) => ({ + ...prev, + name: prev.name || [user.firstName, user.lastName].filter(Boolean).join(" "), + email: prev.email || user.email || "", + })); + }, [user?.userId]); + + // Already-applied check — gated on token PRESENCE (PropelAuth rotates the + // token on refocus; the raw value must never key an effect), run once. + useEffect(() => { + if (!accessToken || appliedCheckRanRef.current) return; + appliedCheckRanRef.current = true; + const check = async () => { + try { + // Timeout so a slow backend can't pin the spinner forever — worst + // case the form renders and the backend 409s a duplicate on submit. + const res = await fetch( + `${apiServerUrl}/api/jobs/${listing.slug}/applications/me`, + { + headers: { Authorization: `Bearer ${accessTokenRef.current}` }, + signal: AbortSignal.timeout(6000), + }, + ); + if (res.ok) { + const data = await res.json(); + if (data.applied) setAlreadyApplied(data); + } + } catch (e) { + // Non-fatal — worst case the backend 409s on submit + } finally { + setCheckingApplied(false); + } + }; + check(); + }, [accessToken, apiServerUrl, listing.slug]); + + useEffect(() => { + if (!accessToken && checkingApplied) { + // No token yet (auth still resolving) — don't block the form forever + const t = setTimeout(() => setCheckingApplied(false), 4000); + return () => clearTimeout(t); + } + return undefined; + }, [accessToken, checkingApplied]); + + const setField = (name, value) => { + setFormData((prev) => { + const next = { ...prev, [name]: value }; + return next; + }); + }; + + const trackStep = (action, label) => { + trackEvent({ + action, + params: { event_label: label, page: "job_application", job: listing.slug }, + }); + }; + + // ----- validation (single top-level error string, mentor-form style) ----- + + const validateAboutYou = () => { + if (!formData.name.trim()) { + setError("Please tell us your name."); + return false; + } + if (!EMAIL_RE.test(formData.email.trim())) { + setError("Please enter a valid email address."); + return false; + } + if (!isLinkedInUrl(formData.linkedinUrl.trim())) { + setError("Please paste your LinkedIn profile URL (it should look like linkedin.com/in/your-name)."); + return false; + } + if (isPhoenixRole && formData.inPersonOk !== "Yes") { + setError( + "This role requires being on-site in Phoenix/Tempe for the event weekend. If that's not possible for you, take a look at our remote roles — we'd still love your help.", + ); + return false; + } + if (!formData.visaAck) { + setError("Please confirm you understand this is an unpaid volunteer role and we cannot sponsor visas."); + return false; + } + setError(""); + return true; + }; + + const validateCommitment = () => { + if (!formData.hoursPerWeek) { + setError("Please tell us how many hours a week you can give."); + return false; + } + if (hoursFloor(formData.hoursPerWeek) < minHours) { + setError( + `This role really needs at least ${minHours} hours a week to succeed. If that's more than you can commit right now, volunteering at the hackathon itself is a great way to plug in — no hard feelings at all.`, + ); + return false; + } + if (!formData.durationCommitment) { + setError("Please tell us how long you can stick with us."); + return false; + } + if (!formData.preferredChannel) { + setError("Please pick a preferred communication channel."); + return false; + } + setError(""); + return true; + }; + + const validateWorkSample = () => { + const chars = formData.workSampleAnswer.trim().length; + if (chars < MIN_WORK_SAMPLE_CHARS) { + setError( + `Your work sample needs a bit more depth — at least ${MIN_WORK_SAMPLE_CHARS} characters (you have ${chars}). This is the part we read most closely.`, + ); + return false; + } + if (!formData.resumeUrl) { + setError("Please upload your resume (PDF)."); + return false; + } + setError(""); + return true; + }; + + const validateVideo = () => { + if (!formData.videoUrl) { + setError("The video is required — it's how we know we're talking to you. Upload a file or paste a YouTube/Vimeo/Loom link."); + return false; + } + setError(""); + return true; + }; + + const STEP_VALIDATORS = [validateAboutYou, validateCommitment, validateWorkSample, validateVideo]; + + const handleNext = () => { + if (!STEP_VALIDATORS[activeStep]()) return; + if (activeStep === STEPS.length - 1) { + handleSubmit(); + return; + } + const next = activeStep + 1; + setActiveStep(next); + trackStep("job_app_step", STEPS[next]); + scrollToStepContent(stepContentRef); + }; + + const handleBack = () => { + if (activeStep === 0) return; + setActiveStep(activeStep - 1); + scrollToStepContent(stepContentRef); + }; + + const handleSubmit = async () => { + for (const validate of STEP_VALIDATORS) { + if (!validate()) return; + } + setSubmitting(true); + setError(""); + try { + const recaptchaToken = await getRecaptchaToken("job_application"); + if (!recaptchaToken && process.env.NODE_ENV === "production") { + setError("Could not verify you're human — please refresh and try again."); + return; + } + + const payload = { + name: formData.name.trim(), + email: formData.email.trim(), + pronouns: formData.pronouns, + phone: formData.phone.trim(), + location: formData.location.trim(), + linkedin_url: formData.linkedinUrl.trim(), + resume_url: formData.resumeUrl, + video_url: formData.videoUrl, + hours_per_week: formData.hoursPerWeek, + duration_commitment: formData.durationCommitment, + preferred_channel: formData.preferredChannel, + slack_member: formData.slackMember, + in_person_ok: formData.inPersonOk === "Yes", + visa_ack: formData.visaAck, + work_sample_answer: formData.workSampleAnswer.trim(), + why_ohack: formData.whyOhack.trim(), + referral_source: formData.referralSource.trim(), + recaptchaToken, + }; + + const res = await fetch(`${apiServerUrl}/api/jobs/${listing.slug}/apply`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessTokenRef.current}`, + }, + body: JSON.stringify(payload), + }); + const data = await res.json().catch(() => ({})); + + if (res.status === 409) { + setAlreadyApplied({ applied: true, status: "submitted" }); + return; + } + if (!res.ok) { + setError(data.error || "Something went wrong submitting your application — please try again."); + trackStep("job_app_submit_error", data.error || String(res.status)); + return; + } + + clearSavedData(); + setSuccess(true); + trackStep("job_app_submit", listing.slug); + scrollToStepContent(stepContentRef); + } catch (e) { + setError("Network error — please check your connection and try again."); + trackStep("job_app_submit_error", "network"); + } finally { + setSubmitting(false); + } + }; + + // ----- step content ----- + + const renderAboutYou = () => ( + + Step 1 of 4 + + About you + + + The basics, plus where to find your professional footprint. + + + + + setField("pronouns", value)} + /> + + + + + {isPhoenixRole && ( + + + Can you be on-site in Phoenix/Tempe, including the full event weekend? + + + + This role runs the physical event, so it can't be done remotely. + + + )} + + + + This is an unpaid volunteer role with a 501(c)(3) + nonprofit. We are unable to sponsor visas. What we can offer: + real portfolio work, Hearts toward certificates, and LinkedIn + recommendations & references from work that actually shipped. + + + setField("visaAck", e.target.checked)} + sx={refinedChoiceSx} + /> + } + label="I understand this is an unpaid volunteer role and that Opportunity Hack cannot sponsor visas." + /> + + ); + + const renderCommitment = () => ( + + Step 2 of 4 + + Commitment & communication + + + Honest numbers beat optimistic ones — we plan around what you tell us + here. + + + + + Hours per week you can reliably give + + + + This role needs about {listing.hours_per_week_label} hours a week. + + + + + How long can you stick around? + + {listing.duration_ask} + + + + Preferred way to coordinate + + + We run on Slack day-to-day, with email for anything formal. + + + + + Are you in our Slack yet? + + + + + + ); + + const renderWorkSample = () => { + const chars = formData.workSampleAnswer.trim().length; + return ( + + Step 3 of 4 + + The work sample + + + This is the fun part — and the part we read most closely. There's + no single right answer; we want to see how you think. + + + + + {listing.work_sample_prompt || ""} + + + + + + + + { + setField("resumeUrl", url); + if (url) trackStep("job_app_resume_uploaded", listing.slug); + }} + accessToken={accessToken} + apiServerUrl={apiServerUrl} + /> + + ); + }; + + const renderVideoAndReview = () => ( + + Step 4 of 4 + + Your video & review + + + A short video (under 2 minutes) answering the prompts below. Phone + camera is perfect — we care about the person, not the production. + + + + + Answer these on camera: + + + {(listing.video_prompts || []).map((prompt) => ( + + {prompt} + + ))} + + + + setField("videoUrl", url)} + accessToken={accessToken} + apiServerUrl={apiServerUrl} + onVideoAdded={(method) => trackStep("job_app_video_added", method)} + /> + + + + Quick review + + + {formData.name} · {formData.email} +
+ {formData.hoursPerWeek} per week · {formData.durationCommitment} +
+ Resume {formData.resumeUrl ? "✓" : "✗"} · Video {formData.videoUrl ? "✓" : "✗"} +
+ + After you submit, we'll email a confirmation — reply to + it within 5 days to confirm your application is active. + Consider it the first task of the role. + +
+
+ ); + + const stepRenderers = [renderAboutYou, renderCommitment, renderWorkSample, renderVideoAndReview]; + + // ----- top-level render states ----- + + if (checkingApplied) { + return ( + + + + ); + } + + if (alreadyApplied) { + return ( + + + Application on file + + You've already applied — nice. + + + + We have your application for this role + {alreadyApplied.status ? ` (status: ${alreadyApplied.status})` : ""}. + Check your inbox for the confirmation email — if you haven't + replied to it yet, doing so confirms your application is active. + We'll reach out from questions@ohack.org for next steps. + + + + + ); + } + + if (success) { + return ( + + + Application received + + Submitted — one thing left. + + + + Your application is in. We just sent a confirmation email —{" "} + reply to it within 5 days to confirm your + application is active. We review by hand and typically reach out + within a week to set up a call. + + + + While you wait: join our{" "} + Slack community and say + hi in #introductions — it's where the actual work happens. + + + + + ); + } + + return ( + + + + + + + {STEPS.map((label) => ( + + {label} + + ))} + + + + + {/* noValidate: the required MUI Selects render hidden native inputs; + without it the browser's constraint validation silently blocks + submit (invalid control not focusable → no submit event at all). + Our per-step JS validators own all validation. */} + { + e.preventDefault(); + handleNext(); + }} + > + + {stepRenderers[activeStep]()} + + + {error && ( + + {error} + + )} + + + + + + + + + + ); +} diff --git a/src/components/Jobs/ResumeUploadField.js b/src/components/Jobs/ResumeUploadField.js new file mode 100644 index 00000000..6394cad6 --- /dev/null +++ b/src/components/Jobs/ResumeUploadField.js @@ -0,0 +1,229 @@ +import React, { useRef, useState } from "react"; +import { + Box, + Button, + LinearProgress, + Link, + Typography, +} from "@mui/material"; +import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; +import { + ghostButtonSx, + refinedInlineLinkSx, +} from "../ApplicationForm/refinedStyles"; + +// Resume (PDF) upload for the volunteer job application form. A controlled +// field like IntroVideoField: the uploaded file's CDN URL lives in the +// parent's formData; this component only acquires it via the jobs signed-URL +// mint (POST /api/jobs/apply/resume-upload-url → XHR PUT to GCS). Requires a +// logged-in user (the endpoint resolves the caller's user doc to build the +// job_applications// path the backend later verifies on submit). +// Styling assumes an ancestor . + +const MAX_BYTES = 10 * 1024 * 1024; // keep in sync with backend MAX_RESUME_BYTES +const CONTENT_TYPE = "application/pdf"; + +export default function ResumeUploadField({ + value, + onChange, + accessToken, + apiServerUrl = process.env.NEXT_PUBLIC_API_SERVER_URL, + label = "Your resume (PDF)", + helperText = "One PDF, up to 10MB. We use it to understand your background before our call — polish matters less than honesty.", + required = false, + error = "", + onUploaded, // optional () => void, for analytics +}) { + const [progress, setProgress] = useState(null); // null | 0..100 + const [busy, setBusy] = useState(false); + const [localError, setLocalError] = useState(""); + const fileInputRef = useRef(null); + + const handleFile = async (event) => { + const file = event.target.files?.[0]; + event.target.value = ""; // allow re-selecting the same file + if (!file) return; + setLocalError(""); + + const isPdf = + file.type === CONTENT_TYPE || /\.pdf$/i.test(file.name || ""); + if (!isPdf) { + setLocalError("Please choose a PDF file"); + return; + } + if (file.size > MAX_BYTES) { + setLocalError("Resume must be under 10MB"); + return; + } + + setBusy(true); + setProgress(0); + try { + const urlRes = await fetch( + `${apiServerUrl}/api/jobs/apply/resume-upload-url`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + content_type: CONTENT_TYPE, + content_length: file.size, + }), + }, + ); + const urlData = await urlRes.json().catch(() => ({})); + if (!urlRes.ok) { + setLocalError(urlData.error || "Could not start the upload"); + return; + } + + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("PUT", urlData.upload_url); + // GCS verifies these against the signed headers + for (const [header, headerValue] of Object.entries( + urlData.required_headers || {}, + )) { + xhr.setRequestHeader(header, headerValue); + } + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) + setProgress(Math.round((e.loaded / e.total) * 100)); + }; + xhr.onload = () => + xhr.status >= 200 && xhr.status < 300 + ? resolve() + : reject(new Error(`Upload failed (${xhr.status})`)); + xhr.onerror = () => + reject(new Error("Upload failed — check your connection")); + xhr.send(file); + }); + + onChange(urlData.final_url); + if (onUploaded) onUploaded(); + } catch (err) { + setLocalError(err.message || "Upload failed"); + } finally { + setProgress(null); + setBusy(false); + } + }; + + const shownError = localError || error; + + return ( + + + {label} + {required ? " *" : ""} + + + {helperText} + + + + {value ? ( + + + Your resume:{" "} + + View uploaded PDF + + + + + + + + ) : ( + + )} + + {progress !== null && ( + + + + Uploading… {progress}% + + + )} + + + {shownError && ( + + {shownError} + + )} + + + + ); +} diff --git a/src/components/Jobs/ShareRow.js b/src/components/Jobs/ShareRow.js new file mode 100644 index 00000000..69aee4f2 --- /dev/null +++ b/src/components/Jobs/ShareRow.js @@ -0,0 +1,74 @@ +import React, { useState } from "react"; +import { trackEvent } from "../../lib/ga"; + +// Share buttons for a job listing (copy link / LinkedIn / X). Plain refined +// .ohx-btn--ghost anchors so it works anywhere inside a . The +// share text is pre-written so a supporter can post in one click. + +export default function ShareRow({ url, title, slug, heading }) { + const [copied, setCopied] = useState(false); + + const shareText = `${title} — a volunteer role at Opportunity Hack. Real portfolio work for social good:`; + const encodedUrl = encodeURIComponent(url); + const encodedText = encodeURIComponent(shareText); + + const track = (method) => { + trackEvent({ + action: "job_share", + params: { event_label: slug, method }, + }); + }; + + const handleCopy = async () => { + track("copy_link"); + try { + await navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (e) { + // Clipboard API unavailable (http / permissions) — best effort only + window.prompt("Copy this link:", url); + } + }; + + return ( +
+ {heading && ( +

+ {heading} +

+ )} +
+ + track("linkedin")} + > + Share on LinkedIn + + track("x")} + > + Share on X + +
+
+ ); +} diff --git a/src/components/admin/AdminNavigation.js b/src/components/admin/AdminNavigation.js index 41e1aeca..7ddd7f99 100644 --- a/src/components/admin/AdminNavigation.js +++ b/src/components/admin/AdminNavigation.js @@ -44,6 +44,7 @@ import { Article as ArticleIcon, Feedback as FeedbackIcon, SmartToy as SmartToyIcon, + WorkOutline as WorkOutlineIcon, } from "@mui/icons-material"; import HandshakeIcon from '@mui/icons-material/Handshake'; @@ -147,6 +148,11 @@ const adminPages = [ label: "Blog", icon: }, + { + path: "/admin/jobs", + label: "Jobs", + icon: + }, { path: "/admin/praise-bot", label: "Praise Bot", diff --git a/src/components/admin/jobs/ApplicationsTab.js b/src/components/admin/jobs/ApplicationsTab.js new file mode 100644 index 00000000..802dd0a0 --- /dev/null +++ b/src/components/admin/jobs/ApplicationsTab.js @@ -0,0 +1,585 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Divider, + FormControl, + IconButton, + InputLabel, + MenuItem, + Paper, + Select, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import { + Description as ResumeIcon, + LinkedIn as LinkedInIcon, + PlayCircleOutline as VideoIcon, +} from "@mui/icons-material"; +import * as ga from "../../../lib/ga"; + +// Job application review (job_applications collection via /api/jobs/admin/*). +// Row click opens a detail Dialog with the work sample, links, status/notes, +// and the one-click decision actions (kind rejection / accept) that send the +// backend's templated emails. + +const STATUS_OPTIONS = [ + "submitted", + "confirmed", + "call_scheduled", + "accepted", + "rejected", + "withdrawn", +]; + +const STATUS_CHIP = { + submitted: { color: "info", label: "Submitted" }, + confirmed: { color: "primary", label: "Confirmed" }, + call_scheduled: { color: "warning", label: "Call scheduled" }, + accepted: { color: "success", label: "Accepted" }, + rejected: { color: "default", label: "Rejected" }, + withdrawn: { color: "default", label: "Withdrawn" }, +}; + +const formatDate = (iso) => { + if (!iso) return "—"; + try { + return new Date(iso).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); + } catch { + return iso; + } +}; + +export default function ApplicationsTab({ accessToken, orgId, isAdmin, onSnack }) { + const [applications, setApplications] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [roleFilter, setRoleFilter] = useState("all"); + const [statusFilter, setStatusFilter] = useState("all"); + const [selectedId, setSelectedId] = useState(null); + const [notesDraft, setNotesDraft] = useState(""); + const [decision, setDecision] = useState(null); // null | {type, note} + const [busy, setBusy] = useState(false); + + const apiBase = process.env.NEXT_PUBLIC_API_SERVER_URL; + const authHeaders = useCallback( + () => ({ + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + ...(orgId ? { "X-Org-Id": orgId } : {}), + }), + [accessToken, orgId], + ); + + const fetchApplications = useCallback(async () => { + if (!isAdmin || !accessToken) return; + setLoading(true); + setError(null); + try { + const res = await fetch(`${apiBase}/api/jobs/admin/applications`, { + headers: authHeaders(), + }); + if (!res.ok) throw new Error(`Failed to load applications (${res.status})`); + const data = await res.json(); + setApplications(data.applications || []); + } catch (err) { + setError(err.message || "Failed to load"); + } finally { + setLoading(false); + } + }, [apiBase, accessToken, isAdmin, authHeaders]); + + useEffect(() => { + fetchApplications(); + }, [fetchApplications]); + + // Derive the live record from list state, never a click-time snapshot + // (CLAUDE.md "stale selected item" gotcha). + const selected = useMemo( + () => applications.find((a) => a.id === selectedId) || null, + [applications, selectedId], + ); + + const roleOptions = useMemo(() => { + const map = new Map(); + applications.forEach((a) => map.set(a.listing_slug, a.listing_title)); + return Array.from(map.entries()); + }, [applications]); + + const filtered = useMemo( + () => + applications.filter((a) => { + if (roleFilter !== "all" && a.listing_slug !== roleFilter) return false; + if (statusFilter !== "all" && (a.status || "submitted") !== statusFilter) + return false; + return true; + }), + [applications, roleFilter, statusFilter], + ); + + const openDetail = (application) => { + setSelectedId(application.id); + setNotesDraft(application.admin_notes || ""); + }; + + const patchApplication = async (applicationId, patch, successMessage) => { + setBusy(true); + try { + const res = await fetch(`${apiBase}/api/jobs/admin/applications/${applicationId}`, { + method: "PATCH", + headers: authHeaders(), + body: JSON.stringify(patch), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || `Update failed (${res.status})`); + setApplications((prev) => + prev.map((a) => (a.id === applicationId ? { ...a, ...patch } : a)), + ); + if (successMessage) onSnack(successMessage, "success"); + } catch (err) { + onSnack(err.message || "Update failed", "error"); + } finally { + setBusy(false); + } + }; + + const handleDecision = async () => { + if (!decision || !selected) return; + setBusy(true); + try { + const res = await fetch( + `${apiBase}/api/jobs/admin/applications/${selected.id}/decision`, + { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + decision: decision.type, + personal_note: decision.note || "", + }), + }, + ); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || `Decision failed (${res.status})`); + setApplications((prev) => + prev.map((a) => (a.id === selected.id ? { ...a, status: decision.type } : a)), + ); + onSnack( + data.email_sent + ? `Marked ${decision.type} — email sent to ${selected.email}` + : `Marked ${decision.type} (email could not be sent — follow up manually)`, + data.email_sent ? "success" : "warning", + ); + ga.trackStructuredEvent(ga.EventCategory.ADMIN, "admin_jobs_decision", decision.type); + setDecision(null); + } catch (err) { + onSnack(err.message || "Decision failed", "error"); + } finally { + setBusy(false); + } + }; + + return ( + + + Every application sends the applicant a confirmation email asking them to + reply within 5 days (the responsiveness test) and an FYI to + questions@ohack.org. Mark someone Confirmed when they + reply. Decisions below send the templated warm emails. + + + + + + Role + + + + setStatusFilter("all")} + /> + {STATUS_OPTIONS.map((s) => { + const count = applications.filter( + (a) => (a.status || "submitted") === s, + ).length; + if (!count) return null; + return ( + setStatusFilter(s)} + /> + ); + })} + + + + + {error && {error}} + + {loading ? ( + + + + ) : ( + + + + + + Name + Role + Status + Hrs/wk + Duration + Applied + Links + + + + {filtered.map((a) => { + const chip = STATUS_CHIP[a.status || "submitted"] || STATUS_CHIP.submitted; + return ( + openDetail(a)} + > + + + {a.name} + + + {a.email} + + + {a.listing_title} + + + + {a.hours_per_week} + {a.duration_commitment} + {formatDate(a.timestamp)} + e.stopPropagation()}> + {a.resume_url && ( + + + + + + )} + {a.video_url && ( + + + + + + )} + {a.linkedin_url && ( + + + + + + )} + + + ); + })} + {filtered.length === 0 && ( + + + + {applications.length === 0 + ? "No applications yet." + : "No applications match the current filters."} + + + + )} + +
+
+
+ )} + + {/* --------- Detail dialog --------- */} + setSelectedId(null)} + maxWidth="md" + fullWidth + > + {selected && ( + <> + + {selected.name} — {selected.listing_title} + + + + + {selected.email} + {selected.pronouns ? ` · ${selected.pronouns}` : ""} + {selected.phone ? ` · ${selected.phone}` : ""} + {selected.location ? ` · ${selected.location}` : ""} + {" · applied "} + {formatDate(selected.timestamp)} + + + + {selected.resume_url && ( + + )} + {selected.video_url && ( + + )} + {selected.linkedin_url && ( + + )} + + + + + + + Commitment + + + {selected.hours_per_week} per week · {selected.duration_commitment} · + prefers {selected.preferred_channel || "—"} + {selected.slack_member ? ` · Slack: ${selected.slack_member}` : ""} + {selected.in_person_ok ? " · can be on-site" : ""} + + {selected.referral_source && ( + + Heard about us via: {selected.referral_source} + + )} + + + + + Work sample answer + + + {selected.work_sample_answer} + + + + {selected.why_ohack && ( + + + Why Opportunity Hack + + + {selected.why_ohack} + + + )} + + + + + + Status + + + setNotesDraft(e.target.value)} + onBlur={() => { + if (notesDraft !== (selected.admin_notes || "")) { + patchApplication(selected.id, { admin_notes: notesDraft }, "Notes saved"); + } + }} + /> + + + {(selected.sent_emails || []).length > 0 && ( + + Emails sent:{" "} + {selected.sent_emails + .map((e) => `${e.recipient_type} (${formatDate(e.timestamp)})`) + .join(", ")} + + )} + + + + + + + + + + + )} + + + {/* --------- Decision confirm --------- */} + setDecision(null)} maxWidth="sm" fullWidth> + + {decision?.type === "accepted" + ? `Accept ${selected?.name}?` + : `Send a kind rejection to ${selected?.name}?`} + + + + {decision?.type === "accepted" + ? "This emails them that we'd like to move forward and that questions@ohack.org will reach out to schedule a call." + : "This sends the warm, door-stays-open rejection email (thanks them for the effort, points to mentoring/judging/volunteering and Slack, and invites them to apply again). No further action needed from you."} + + setDecision((prev) => ({ ...prev, note: e.target.value }))} + /> + + + + + + +
+ ); +} diff --git a/src/components/admin/jobs/ListingsTab.js b/src/components/admin/jobs/ListingsTab.js new file mode 100644 index 00000000..31978ec3 --- /dev/null +++ b/src/components/admin/jobs/ListingsTab.js @@ -0,0 +1,537 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + FormControl, + IconButton, + InputLabel, + MenuItem, + Paper, + Select, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import { + Add as AddIcon, + Delete as DeleteIcon, + Edit as EditIcon, + Launch as LaunchIcon, + Visibility as VisibilityIcon, + VisibilityOff as VisibilityOffIcon, +} from "@mui/icons-material"; +import * as ga from "../../../lib/ga"; + +// Volunteer job listings CRUD (job_listings collection via /api/jobs/admin/*). +// A simple table + edit Dialog — there are only ever a handful of listings, so +// no blog-style editor pages. Slug is create-only (it's the Firestore doc id +// and the public URL). + +const STATUS_OPTIONS = ["draft", "published", "hidden", "closed"]; +const LOCATION_OPTIONS = [ + { value: "remote", label: "Remote" }, + { value: "phoenix_in_person", label: "Phoenix — in person" }, + { value: "hybrid", label: "Hybrid / remote-friendly" }, +]; + +const statusChipColor = (status) => { + if (status === "published") return "success"; + if (status === "draft") return "warning"; + if (status === "closed") return "default"; + return "default"; // hidden +}; + +const slugify = (text) => + (text || "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + +const EMPTY_LISTING = { + slug: "", + title: "", + status: "draft", + location_type: "remote", + location_label: "", + hours_per_week_label: "", + min_hours_per_week: 0, + duration_ask: "", + summary: "", + description_markdown: "", + work_sample_prompt: "", + video_prompts: [], + valid_through: "", +}; + +export default function ListingsTab({ accessToken, orgId, isAdmin, onSnack }) { + const [listings, setListings] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [editing, setEditing] = useState(null); // null | {isNew, draft} + const [confirmDelete, setConfirmDelete] = useState(null); + const [saving, setSaving] = useState(false); + + const apiBase = process.env.NEXT_PUBLIC_API_SERVER_URL; + const authHeaders = useCallback( + () => ({ + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + ...(orgId ? { "X-Org-Id": orgId } : {}), + }), + [accessToken, orgId], + ); + + const fetchListings = useCallback(async () => { + if (!isAdmin || !accessToken) return; + setLoading(true); + setError(null); + try { + const res = await fetch(`${apiBase}/api/jobs/admin/listings`, { + headers: authHeaders(), + }); + if (!res.ok) throw new Error(`Failed to load listings (${res.status})`); + const data = await res.json(); + setListings(data.listings || []); + } catch (err) { + setError(err.message || "Failed to load"); + } finally { + setLoading(false); + } + }, [apiBase, accessToken, isAdmin, authHeaders]); + + useEffect(() => { + fetchListings(); + }, [fetchListings]); + + const openNew = () => setEditing({ isNew: true, draft: { ...EMPTY_LISTING } }); + const openEdit = (listing) => + setEditing({ + isNew: false, + draft: { ...EMPTY_LISTING, ...listing }, + }); + + const setDraftField = (field, value) => + setEditing((prev) => ({ ...prev, draft: { ...prev.draft, [field]: value } })); + + const handleSave = async () => { + const { isNew, draft } = editing; + const payload = { + title: draft.title, + status: draft.status, + location_type: draft.location_type, + location_label: draft.location_label, + hours_per_week_label: draft.hours_per_week_label, + min_hours_per_week: Number(draft.min_hours_per_week) || 0, + duration_ask: draft.duration_ask, + summary: draft.summary, + description_markdown: draft.description_markdown, + work_sample_prompt: draft.work_sample_prompt, + video_prompts: (Array.isArray(draft.video_prompts) + ? draft.video_prompts + : String(draft.video_prompts).split("\n") + ) + .map((p) => p.trim()) + .filter(Boolean), + valid_through: draft.valid_through, + }; + setSaving(true); + try { + const res = isNew + ? await fetch(`${apiBase}/api/jobs/admin/listings`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ ...payload, slug: draft.slug }), + }) + : await fetch(`${apiBase}/api/jobs/admin/listings/${draft.slug}`, { + method: "PATCH", + headers: authHeaders(), + body: JSON.stringify(payload), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || `Save failed (${res.status})`); + onSnack(`Saved "${draft.title}"`, "success"); + ga.trackStructuredEvent(ga.EventCategory.ADMIN, "admin_jobs_listing_save", draft.slug); + setEditing(null); + await fetchListings(); + } catch (err) { + onSnack(err.message || "Save failed", "error"); + } finally { + setSaving(false); + } + }; + + const handleQuickStatus = async (listing, status) => { + try { + const res = await fetch(`${apiBase}/api/jobs/admin/listings/${listing.slug}`, { + method: "PATCH", + headers: authHeaders(), + body: JSON.stringify({ status }), + }); + if (!res.ok) throw new Error(`Update failed (${res.status})`); + setListings((prev) => + prev.map((l) => (l.slug === listing.slug ? { ...l, status } : l)), + ); + onSnack(`"${listing.title}" is now ${status}`, "success"); + } catch (err) { + onSnack(err.message || "Update failed", "error"); + } + }; + + const handleDelete = async () => { + const target = confirmDelete; + setConfirmDelete(null); + if (!target) return; + try { + const res = await fetch(`${apiBase}/api/jobs/admin/listings/${target.slug}`, { + method: "DELETE", + headers: authHeaders(), + }); + if (!res.ok) throw new Error(`Delete failed (${res.status})`); + setListings((prev) => prev.filter((l) => l.slug !== target.slug)); + onSnack(`Deleted "${target.title}"`, "success"); + ga.trackStructuredEvent(ga.EventCategory.ADMIN, "admin_jobs_listing_delete", target.slug); + } catch (err) { + onSnack(err.message || "Delete failed", "error"); + } + }; + + return ( + + + + Volunteer roles shown on{" "} + + ohack.dev/jobs + + . New listings start as drafts; publish when the copy is ready. Hide + takes a listing off the site without deleting applications. + + + + + {error && {error}} + + {loading ? ( + + + + ) : ( + + + + + + Title + Status + Location + Hrs/wk + Valid through + Actions + + + + {listings.map((listing) => ( + + + + {listing.title} + + + /jobs/{listing.slug} + + + + + + {listing.location_label || listing.location_type} + {listing.hours_per_week_label} + {listing.valid_through || "—"} + + + openEdit(listing)}> + + + + + + + + + {listing.status === "published" ? ( + + handleQuickStatus(listing, "hidden")} + > + + + + ) : ( + + handleQuickStatus(listing, "published")} + > + + + + )} + + setConfirmDelete(listing)} + > + + + + + + ))} + {listings.length === 0 && ( + + + + No listings yet — click New listing, or run{" "} + scripts/seed_job_listings.py --apply on the + backend to seed the three Fall 2026 roles. + + + + )} + +
+
+
+ )} + + {/* --------- Edit / create dialog --------- */} + setEditing(null)} maxWidth="md" fullWidth> + {editing?.isNew ? "New listing" : `Edit: ${editing?.draft.title}`} + {editing && ( + + + { + setDraftField("title", e.target.value); + if (editing.isNew && !editing.draft.slugTouched) { + setEditing((prev) => ({ + ...prev, + draft: { + ...prev.draft, + title: e.target.value, + slug: slugify(e.target.value), + }, + })); + } + }} + /> + + setEditing((prev) => ({ + ...prev, + draft: { ...prev.draft, slug: slugify(e.target.value), slugTouched: true }, + })) + } + /> + + + Status + + + + Location type + + + + + setDraftField("location_label", e.target.value)} + /> + setDraftField("hours_per_week_label", e.target.value)} + /> + setDraftField("min_hours_per_week", e.target.value)} + /> + + + setDraftField("duration_ask", e.target.value)} + /> + setDraftField("valid_through", e.target.value)} + /> + + setDraftField("summary", e.target.value)} + /> + setDraftField("description_markdown", e.target.value)} + /> + setDraftField("work_sample_prompt", e.target.value)} + /> + setDraftField("video_prompts", e.target.value)} + /> + + + )} + + + + + + + {/* --------- Delete confirm --------- */} + setConfirmDelete(null)}> + Delete this listing? + + + This permanently deletes {confirmDelete?.title}. The + public page starts returning 404. Applications already submitted are + kept. If you just want it off the site, use Hide instead. + + + + + + + +
+ ); +} diff --git a/src/pages/admin/index.js b/src/pages/admin/index.js index fc827c54..2e45d6ed 100644 --- a/src/pages/admin/index.js +++ b/src/pages/admin/index.js @@ -31,6 +31,7 @@ import { Article as ArticleIcon, Feedback as FeedbackIcon, SmartToy as SmartToyIcon, + WorkOutline as WorkOutlineIcon, } from "@mui/icons-material"; import HandshakeIcon from '@mui/icons-material/Handshake'; @@ -113,6 +114,12 @@ const adminPages = [ description: "Write, edit, and manage blog posts with markdown + SEO", icon: }, + { + path: "/admin/jobs", + label: "Volunteer Jobs", + description: "Manage /jobs listings and review organizer applications", + icon: + }, { path: "/admin/feedback", label: "Feedback", diff --git a/src/pages/admin/jobs/index.js b/src/pages/admin/jobs/index.js new file mode 100644 index 00000000..6d50db1f --- /dev/null +++ b/src/pages/admin/jobs/index.js @@ -0,0 +1,118 @@ +import React, { useEffect, useState } from "react"; +import { useRouter } from "next/router"; +import Head from "next/head"; +import dynamic from "next/dynamic"; +import { + useAuthInfo, + RequiredAuthProvider, + RedirectToLogin, +} from "@propelauth/react"; +import { Box, Tab, Tabs, Typography } from "@mui/material"; +import AdminPage from "../../../components/admin/AdminPage"; +import * as ga from "../../../lib/ga"; + +// Volunteer job board admin: listings CRUD + application review. +// Two tabs shallow-synced to ?tab=listings|applications (communication-page +// pattern). Only the active tab mounts (lazy fetch). + +const ListingsTab = dynamic(() => import("../../../components/admin/jobs/ListingsTab"), { + ssr: false, +}); +const ApplicationsTab = dynamic( + () => import("../../../components/admin/jobs/ApplicationsTab"), + { ssr: false }, +); + +const TAB_SLUGS = ["listings", "applications"]; + +const AdminJobsPage = () => { + const router = useRouter(); + const { accessToken, userClass } = useAuthInfo(); + const org = userClass?.getOrgByName("Opportunity Hack Org"); + const isAdmin = !!org?.hasPermission("volunteer.admin"); + const orgId = org?.orgId; + + const [snackbar, setSnackbar] = useState({ open: false, message: "", severity: "success" }); + const onSnack = (message, severity = "success") => + setSnackbar({ open: true, message, severity }); + + const tabFromQuery = TAB_SLUGS.indexOf(router.query.tab); + const activeTab = tabFromQuery === -1 ? 0 : tabFromQuery; + + const handleTabChange = (_e, value) => { + router.replace( + { pathname: router.pathname, query: { ...router.query, tab: TAB_SLUGS[value] } }, + undefined, + { shallow: true, scroll: false }, + ); + }; + + useEffect(() => { + if (isAdmin) { + ga.trackStructuredEvent(ga.EventCategory.ADMIN, "admin_jobs_view", TAB_SLUGS[activeTab]); + } + }, [isAdmin, activeTab]); + + if (!isAdmin) { + return ( + + } + > + + You do not have permission to view this page. + + + ); + } + + return ( + + } + > + + Jobs Admin — Opportunity Hack + + + setSnackbar((prev) => ({ ...prev, open: false }))} + > + + + + + + + {activeTab === 0 ? ( + + ) : ( + + )} + + + ); +}; + +export default AdminJobsPage; diff --git a/src/pages/jobs/[slug].js b/src/pages/jobs/[slug].js new file mode 100644 index 00000000..69d716c2 --- /dev/null +++ b/src/pages/jobs/[slug].js @@ -0,0 +1,374 @@ +import React, { useEffect } from "react"; +import Head from "next/head"; +import Link from "next/link"; +import dynamic from "next/dynamic"; +import { Box, CircularProgress } from "@mui/material"; +import { useAuthInfo, useRedirectFunctions } from "@propelauth/react"; +import ReactMarkdown from "react-markdown"; + +import ReCaptchaProvider from "../../components/ReCaptchaProvider"; +import { initFacebookPixel, trackEvent } from "../../lib/ga"; +import { RefinedRoot, Eyebrow, Stat, Arrow } from "../../components/design/refined"; +import { eventMarkdownSx } from "../../components/ApplicationForm/refinedStyles"; +import ShareRow from "../../components/Jobs/ShareRow"; + +const JobApplicationForm = dynamic( + () => import("../../components/Jobs/JobApplicationForm"), + { + ssr: false, + loading: () =>
, + }, +); + +const OG_IMAGE = "https://cdn.ohack.dev/ohack.dev/2024_hackathon_1.webp"; + +const LOCATION_LABELS = { + remote: "Remote", + phoenix_in_person: "Phoenix, AZ", + hybrid: "Remote-friendly", +}; + +const cardStyle = { + background: "var(--surface)", + border: "1px solid var(--line)", + borderRadius: 10, + padding: "22px 20px", +}; + +// The apply section's auth gate. The whole app is wrapped in AuthProvider +// (_app.js), so useAuthInfo works here without a page-level RequiredAuthProvider +// — which would hide the public listing content from crawlers and sharers. +const ApplySection = ({ listing }) => { + const { loading, isLoggedIn } = useAuthInfo(); + const { redirectToLoginPage } = useRedirectFunctions(); + + if (loading) { + return ( + + + + ); + } + + if (!isLoggedIn) { + return ( +
+

+ Log in to apply +

+

+ Applying takes an ohack.dev account (free — most people use Google). + Your draft autosaves, and your resume and video uploads are tied to + your account so only our review team can act on them. +

+ +
+ ); + } + + return ; +}; + +const JobDetailPage = ({ listing }) => { + useEffect(() => { + initFacebookPixel(); + }, []); + + const isClosed = listing.status === "closed"; + const canonical = `https://www.ohack.dev/jobs/${listing.slug}`; + const locationShort = LOCATION_LABELS[listing.location_type] || "Remote"; + + return ( + <> + + + + + + {/* ---------------- MASTHEAD ---------------- */} +
+
+

+ + ← All volunteer roles + +

+ Volunteer role · Opportunity Hack +

+ {listing.title} +

+

+ {listing.summary} +

+ +
+
+ +
+
+ +
+
+ +
+
+ + {isClosed ? ( +
+

+ This role is no longer accepting applications. +

+

+ Thanks for your interest — check the other open roles, or join + our Slack to hear about the next one first. +

+ + See open roles + +
+ ) : ( + + )} +
+
+ + {/* ---------------- DESCRIPTION ---------------- */} +
+ + {listing.description_markdown || ""} + + +

+ Commitment: {listing.duration_ask} +

+
+ + {/* ---------------- HOW APPLYING WORKS + SHARE ---------------- */} +
+
+ Before you start +

+ The application takes ~30 minutes — on purpose. +

+

+ It includes a role-specific work sample and a two-minute video + answering prompts on camera. That's our filter for AI-written + and copy-paste applications — and your preview of the actual job. + Phone camera is perfect. +

+ +
+
+ + {/* ---------------- APPLY ---------------- */} + {!isClosed && ( +
+ Apply +

+ Your first task starts here. +

+
+ +
+
+ )} +
+ + ); +}; + +export default function JobDetailPageWithRecaptcha(props) { + return ( + + + + ); +} + +export async function getStaticPaths() { + try { + const res = await fetch(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/jobs`); + const data = res.ok ? await res.json() : { listings: [] }; + return { + paths: (data.listings || []).map((l) => ({ params: { slug: l.slug } })), + fallback: "blocking", + }; + } catch (e) { + return { paths: [], fallback: "blocking" }; + } +} + +export async function getStaticProps({ params }) { + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/jobs/${params.slug}`, + ); + if (res.status === 404) { + return { notFound: true, revalidate: 60 }; + } + if (!res.ok) { + // Rethrow so ISR keeps the last good version on backend blips + throw new Error(`GET /api/jobs/${params.slug} failed: ${res.status}`); + } + const listing = await res.json(); + if (!listing || !listing.slug) { + return { notFound: true, revalidate: 60 }; + } + + const canonical = `https://www.ohack.dev/jobs/${listing.slug}`; + const title = `${listing.title} — Volunteer at Opportunity Hack`; + const description = listing.summary || ""; + const isPhoenix = listing.location_type === "phoenix_in_person"; + + const jobPosting = { + "@type": "JobPosting", + title: listing.title, + description: `

${listing.summary}

\n${listing.description_markdown || ""}`, + employmentType: "VOLUNTEER", + directApply: true, + hiringOrganization: { + "@type": "Organization", + name: "Opportunity Hack", + sameAs: "https://www.ohack.dev", + logo: "https://cdn.ohack.dev/ohack.dev/logos/OpportunityHack_2Letter_Dark_Blue.png", + }, + ...(isPhoenix + ? { + jobLocation: { + "@type": "Place", + address: { + "@type": "PostalAddress", + addressLocality: "Tempe", + addressRegion: "AZ", + addressCountry: "US", + }, + }, + } + : { + jobLocationType: "TELECOMMUTE", + applicantLocationRequirements: { + "@type": "Country", + name: "United States", + }, + }), + }; + // Next.js props must be JSON-serializable — never assign undefined + const datePosted = (listing.posted_at || "").slice(0, 10); + if (datePosted) jobPosting.datePosted = datePosted; + if (listing.valid_through) jobPosting.validThrough = listing.valid_through; + + return { + props: { + listing, + title, + description, + canonical, + openGraphData: [ + { name: "title", property: "title", content: title, key: "title" }, + { name: "og:title", property: "og:title", content: title, key: "ogtitle" }, + { name: "author", property: "author", content: "Opportunity Hack", key: "author" }, + { name: "description", property: "description", content: description, key: "description" }, + { name: "og:description", property: "og:description", content: description, key: "ogdescription" }, + { name: "image", property: "og:image", content: OG_IMAGE, key: "ognameimage" }, + { property: "og:image:width", content: "1200", key: "ogimagewidth" }, + { property: "og:image:height", content: "630", key: "ogimageheight" }, + { name: "url", property: "url", content: canonical, key: "url" }, + { name: "og:url", property: "og:url", content: canonical, key: "ogurl" }, + { property: "og:type", content: "website", key: "ogtype" }, + { 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: OG_IMAGE, key: "twitterimage" }, + ], + structuredData: { + "@context": "https://schema.org", + "@graph": [ + jobPosting, + { + "@type": "WebPage", + "@id": canonical + "#webpage", + url: canonical, + 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: "Volunteer Jobs", item: "https://www.ohack.dev/jobs" }, + { "@type": "ListItem", position: 3, name: listing.title, item: canonical }, + ], + }, + ], + }, + }, + revalidate: 300, + }; +} diff --git a/src/pages/jobs/index.js b/src/pages/jobs/index.js new file mode 100644 index 00000000..b04ec5ad --- /dev/null +++ b/src/pages/jobs/index.js @@ -0,0 +1,439 @@ +import React, { useEffect } from "react"; +import Head from "next/head"; +import Link from "next/link"; +import { initFacebookPixel, trackEvent } from "../../lib/ga"; +import { RefinedRoot, Eyebrow, Stat, Arrow } from "../../components/design/refined"; + +const CANONICAL = "https://www.ohack.dev/jobs"; +const OG_IMAGE = "https://cdn.ohack.dev/ohack.dev/2024_hackathon_1.webp"; + +const trackClick = (button) => { + trackEvent({ action: "click_jobs", params: { button } }); +}; + +const LOCATION_LABELS = { + remote: "Remote", + phoenix_in_person: "Phoenix, AZ · on-site", + hybrid: "Remote-friendly", +}; + +// Single source of truth: rendered as
accordions AND emitted as +// FAQPage JSON-LD (recruit-tech-talent pattern). +const FAQ_ITEMS = [ + { + q: "Are these paid positions?", + a: "No — every role at Opportunity Hack is a volunteer position, including the people who run it. We're a 501(c)(3) nonprofit with very limited funds. What you get instead is real, verifiable experience: a leadership title backed by shipped work, Hearts toward certificates, and LinkedIn recommendations and references from people who watched you deliver.", + }, + { + q: "Can Opportunity Hack sponsor my visa?", + a: "No. We are unable to sponsor visas of any kind. These are unpaid volunteer roles and do not constitute employment.", + }, + { + q: "How much time do these roles take?", + a: "It varies by role — each listing states its expected hours per week, typically 2 to 6. What matters more than the number is reliability: we plan around what you commit to, so an honest 3 hours beats an optimistic 10.", + }, + { + q: "Why does the application require a video?", + a: "Two reasons. First, these roles are communication-heavy, and a two-minute video tells us more than a page of text. Second, it filters out AI-generated and copy-paste applications — we'd rather meet 5 real people than sort through 50 templates. A phone-camera video is perfect; production quality doesn't matter.", + }, + { + q: "Do I need to live in Phoenix?", + a: "Only for the Hackathon Operations Lead, which runs the physical event at ASU in Tempe and requires being on-site for the full event weekend. The Social Media Manager and Mentor Program Lead roles are remote-friendly.", + }, + { + q: "What happens after I apply?", + a: "You'll get a confirmation email right away — reply to it within 5 days to confirm your application is active (consider it the first task). We review every application by hand, typically within a week, then reach out from questions@ohack.org to set up a short call.", + }, + { + q: "Will this actually help my career?", + a: "It has for many of our volunteers. You get a real title, real scope, and public work you can point to in interviews — plus references who can speak to how you operate. Recruiters increasingly want proof over claims, and everything you do here is verifiable.", + }, +]; + +const WHAT_YOU_GET = [ + { + title: "A title backed by real work", + body: "Social Media Manager. Operations Lead. Program Lead. Roles you'd normally need years to reach — earned by shipping, and verifiable by anyone who checks.", + }, + { + title: "References that mean something", + body: "LinkedIn recommendations and interview references from the organizers who watched you deliver under real constraints.", + }, + { + title: "Hearts & certificates", + body: "Our recognition system converts sustained volunteering into certificates and public credit on your ohack.dev portfolio.", + }, + { + title: "A mission worth your weekends", + body: "Everything you do helps nonprofits get software they could never afford. That's the whole point — and it shows in the people you'll work with.", + }, +]; + +const cardStyle = { + background: "var(--surface)", + border: "1px solid var(--line)", + borderRadius: 10, + padding: "26px 24px", +}; + +const JobsIndex = ({ listings }) => { + useEffect(() => { + initFacebookPixel(); + }, []); + + const openRoles = (listings || []).filter((l) => l.status === "published"); + const closedRoles = (listings || []).filter((l) => l.status === "closed"); + + return ( + <> + + + + + + {/* ---------------- HERO ---------------- */} +
+
+ Volunteer with us · Fall 2026 and beyond +

+ Help run Opportunity Hack. +

+

+ We're a volunteer-run nonprofit that gets real software built for + nonprofits. These organizer roles are unpaid — and they're the most + career-real experience you can get without a job offer: real scope, + real deadlines, real references. +

+
+ trackClick("hero_see_roles")} + > + See open roles + + trackClick("hero_about")} + > + What is Opportunity Hack? + +
+
+
+ +
+
+ +
+
+ +
+
+
+
+ + {/* ---------------- OPEN ROLES ---------------- */} +
+ Open roles +

+ Where we need you. +

+ + {openRoles.length === 0 ? ( +
+

No open roles right now.

+

+ New roles are posted here first. Meanwhile, the best way to plug in + is our Slack community — most of our organizers started there. +

+ trackClick("empty_slack")}> + Join the Slack community + +
+ ) : ( +
+ {openRoles.map((role) => ( + trackClick(`role_${role.slug}`)} + > +
+ + {LOCATION_LABELS[role.location_type] || role.location_label} + + {role.hours_per_week_label} hrs/week +
+

+ {role.title} +

+

+ {role.summary} +

+ + View role + + + ))} +
+ )} + + {closedRoles.length > 0 && ( +
+

+ Recently closed +

+
+ {closedRoles.map((role) => ( + + {role.title} · closed + + ))} +
+
+ )} +
+ + {/* ---------------- WHAT YOU GET ---------------- */} +
+
+ Why do this +

+ Unpaid ≠ unrewarded. +

+

+ Everyone who runs Opportunity Hack has a full-time job. We volunteer + because we believe tech can do good — and because the experience is + real in a way side projects never are. +

+
+ {WHAT_YOU_GET.map((item) => ( +
+

{item.title}

+

{item.body}

+
+ ))} +
+
+
+ + {/* ---------------- HOW APPLYING WORKS ---------------- */} +
+ Fair warning +

+ The application is part of the interview. +

+

+ It takes about 30 minutes and includes a role-specific work sample and a + two-minute video. That's deliberate: it shows us how you actually + work, and it filters out AI-written applications. If that sounds fun + rather than annoying, you're exactly who we're looking for. +

+
+ {[ + ["01", "About you", "Basics, LinkedIn, and your resume."], + ["02", "Commitment", "Honest hours per week and how long you'll stay."], + ["03", "Work sample", "A ~15-minute exercise pulled from the actual job."], + ["04", "Short video", "Two minutes on camera — then reply to our email to confirm."], + ].map(([n, title, body]) => ( +
+
{n}
+

{title}

+

{body}

+
+ ))} +
+
+ + {/* ---------------- FAQ ---------------- */} +
+
+ Questions +

+ The honest FAQ. +

+
+ {FAQ_ITEMS.map((item) => ( +
+ + {item.q} + +

+ {item.a} +

+
+ ))} +
+
+
+ + {/* ---------------- FINAL CTA ---------------- */} +
+

+ Do work that matters — and counts. +

+
+ trackClick("footer_roles")}> + Browse open roles + + trackClick("footer_slack")}> + Join our Slack first + +
+
+
+ + ); +}; + +export default JobsIndex; + +export const getStaticProps = async () => { + // Rethrow server errors so ISR keeps serving the last good version rather + // than publishing an empty page on a backend blip (teamPageData pattern). + // A 404 means the backend doesn't serve /api/jobs yet (deploy ordering) — + // render the empty state instead of failing the whole build. + const res = await fetch(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/jobs`); + let listings = []; + if (res.ok) { + const data = await res.json(); + listings = data.listings || []; + } else if (res.status !== 404) { + throw new Error(`GET /api/jobs failed: ${res.status}`); + } + + const title = "Volunteer Jobs: Help Run Opportunity Hack | Phoenix & Remote"; + const description = + "Volunteer leadership roles at Opportunity Hack — social media, hackathon operations (Phoenix, AZ), and mentor program lead. Real portfolio experience, references, and social impact. Unpaid, career-real."; + + return { + props: { + listings, + title, + description, + canonical: CANONICAL, + openGraphData: [ + { name: "title", property: "title", content: title, key: "title" }, + { name: "og:title", property: "og:title", content: title, key: "ogtitle" }, + { name: "author", property: "author", content: "Opportunity Hack", key: "author" }, + { name: "description", property: "description", content: description, key: "description" }, + { name: "og:description", property: "og:description", content: description, key: "ogdescription" }, + { name: "image", property: "og:image", content: OG_IMAGE, key: "ognameimage" }, + { property: "og:image:width", content: "1200", key: "ogimagewidth" }, + { property: "og:image:height", content: "630", key: "ogimageheight" }, + { name: "url", property: "url", content: CANONICAL, key: "url" }, + { name: "og:url", property: "og:url", content: CANONICAL, key: "ogurl" }, + { property: "og:type", content: "website", key: "ogtype" }, + { 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: OG_IMAGE, key: "twitterimage" }, + { + name: "keywords", + property: "keywords", + content: + "volunteer jobs phoenix, nonprofit volunteer opportunities, social media volunteer, hackathon organizer, event operations volunteer, mentor coordinator, volunteer leadership roles, tech volunteering, remote volunteer jobs, resume building volunteer work", + key: "keywords", + }, + ], + structuredData: { + "@context": "https://schema.org", + "@graph": [ + { + "@type": "WebPage", + "@id": CANONICAL + "#webpage", + url: CANONICAL, + 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: "Volunteer Jobs", item: CANONICAL }, + ], + }, + { + "@type": "ItemList", + itemListElement: listings + .filter((l) => l.status === "published") + .map((l, i) => ({ + "@type": "ListItem", + position: i + 1, + url: `https://www.ohack.dev/jobs/${l.slug}`, + })), + }, + { + "@type": "FAQPage", + mainEntity: FAQ_ITEMS.map((item) => ({ + "@type": "Question", + name: item.q, + acceptedAnswer: { "@type": "Answer", text: item.a }, + })), + }, + ], + }, + }, + revalidate: 300, + }; +}; diff --git a/src/pages/server-sitemap.xml.js b/src/pages/server-sitemap.xml.js index cf594bab..88379b0c 100644 --- a/src/pages/server-sitemap.xml.js +++ b/src/pages/server-sitemap.xml.js @@ -63,6 +63,17 @@ export async function getServerSideProps(ctx) { console.error('[server-sitemap] portfolios fetch failed:', e.message); } + // Volunteer job listing pages (published + recently closed) + try { + const data = await fetchJson(`${API_URL}/api/jobs`); + const listings = data.listings || []; + for (const l of listings) { + if (l.slug) fields.push({ loc: `${BASE_URL}/jobs/${l.slug}`, lastmod: now, priority: '0.8', changefreq: 'weekly' }); + } + } catch (e) { + console.error('[server-sitemap] jobs fetch failed:', e.message); + } + ctx.res.setHeader('Cache-Control', 's-maxage=3600, stale-while-revalidate'); return getServerSideSitemapLegacy(ctx, fields); } diff --git a/src/pages/volunteer/index.js b/src/pages/volunteer/index.js index 68ed5c3d..df3005d7 100644 --- a/src/pages/volunteer/index.js +++ b/src/pages/volunteer/index.js @@ -307,6 +307,20 @@ const VolunteerPage = () => {
))} + + {/* Organizer roles cross-link → /jobs */} +
+
+

Want a bigger role? Help run Opportunity Hack.

+

+ We're looking for volunteer organizers — social media, event operations + (Phoenix), and mentor program lead. Real titles, real references, real impact. +

+
+ track("roles_cta", "jobs_page")}> + See organizer roles + +
{/* ADDITIONAL RESOURCES */} From 8320ead33b25432ead426fd9706a687514abbce6 Mon Sep 17 00:00:00 2001 From: Greg V <6913307+gregv@users.noreply.github.com> Date: Sun, 23 Aug 2026 07:44:28 -0700 Subject: [PATCH 15/19] Link /jobs from NavBar, /about, and onboarding; kinder duration wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Volunteer Jobs item to the Get Involved dropdown, an organizer-jobs button on the /about final CTA, and a line in the onboarding roles step. Also renames the 'As long as I'm useful' duration option to 'Ongoing — I'd love to stick around'. Co-Authored-By: Claude Fable 5 --- src/components/Jobs/JobApplicationForm.js | 2 +- src/components/Navbar/Navbar.js | 1 + src/components/Onboarding/RolesSection.js | 3 +++ src/pages/about/index.js | 4 +++- 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/components/Jobs/JobApplicationForm.js b/src/components/Jobs/JobApplicationForm.js index 0b86d1e5..ed1e5b7d 100644 --- a/src/components/Jobs/JobApplicationForm.js +++ b/src/components/Jobs/JobApplicationForm.js @@ -74,7 +74,7 @@ const DURATION_OPTIONS = [ "Through the Fall 2026 event", "3–6 months", "6–12 months", - "As long as I'm useful", + "Ongoing — I'd love to stick around", ]; const CHANNEL_OPTIONS = ["Slack", "Email", "Either works"]; diff --git a/src/components/Navbar/Navbar.js b/src/components/Navbar/Navbar.js index d1fce99a..30460d19 100644 --- a/src/components/Navbar/Navbar.js +++ b/src/components/Navbar/Navbar.js @@ -52,6 +52,7 @@ const hackathonMenuItems = [ const getInvolvedMenuItems = [ ["Onboarding", "/onboarding"], ["Volunteer", "/volunteer"], + ["Volunteer Jobs", "/jobs"], ["Become a Hacker", "/about/hackers"], ["Become a Mentor", "/about/mentors"], ["Become a Judge", "/about/judges"], diff --git a/src/components/Onboarding/RolesSection.js b/src/components/Onboarding/RolesSection.js index 17b09b16..90bc76d4 100644 --- a/src/components/Onboarding/RolesSection.js +++ b/src/components/Onboarding/RolesSection.js @@ -149,6 +149,9 @@ const RolesSection = () => { All applications live on the event pages — pick an upcoming hackathon at{' '} ohack.dev/hack and you'll find the hacker, mentor, judge, and volunteer application forms right on the event's page. + Want a bigger, ongoing role? We also recruit volunteer organizers (social media, event + operations, mentor program) at{' '} + ohack.dev/jobs. {/* Videos */} diff --git a/src/pages/about/index.js b/src/pages/about/index.js index 809f3ffd..413f885a 100644 --- a/src/pages/about/index.js +++ b/src/pages/about/index.js @@ -407,10 +407,12 @@ export default function AboutUsPage() { Start your journey

Find your role

- Hack solutions, mentor teams, volunteer at events, or judge projects — there's a place for you. + Hack solutions, mentor teams, volunteer at events, judge projects — or take an + organizer role and help run Opportunity Hack itself.

Explore roles + See organizer jobs Join the community
From 4339c3116ab49980d1ff18f6f8974f7ef033aced Mon Sep 17 00:00:00 2001 From: Greg V <6913307+gregv@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:11:17 -0700 Subject: [PATCH 16/19] Admin judge review: inline intro-video player + LMS training status - Extract the LMS Convex transport from JudgeTrainingGate into a shared src/lib/lmsClient.js (gate's public exports unchanged) and add use-judge-training-status: anonymous cert verification for every admin plus an authed attempts rollup (listQuizzes/getQuizResults/listUsers) when the admin's LMS account has an admin/editor role, joined to judge applications by email behind a 60s cache with one batched setState. - VolunteerWorkbench hosts the single fetch (Judges tab only) and one page-level VideoDisplay dialog shared by the table and review views. - ApplicationReviewCard gains a judge-only JudgeTrainingPanel (video thumbnail, per-quiz verification rows with score/issue date/attempts, judgeTrainingCompleted chip); VolunteerTable gains judges-only Training and Video columns with a detail tooltip and mobile-card chip. - Fix a pre-existing card bug where links rendered as plain text when expanded (missing isLink arg) and hoist the 3 duplicated isLink arrays into one LINK_FIELDS constant. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 11 + .../ApplicationForm/JudgeTrainingGate.js | 141 +------- src/components/admin/ApplicationReviewCard.js | 315 ++++++++++++++++-- src/components/admin/ApplicationReviewList.js | 10 + src/components/admin/VolunteerTable.js | 132 +++++++- .../admin/volunteer/VolunteerWorkbench.js | 66 +++- src/hooks/use-judge-training-status.js | 265 +++++++++++++++ src/lib/lmsClient.js | 128 +++++++ 8 files changed, 905 insertions(+), 163 deletions(-) create mode 100644 src/hooks/use-judge-training-status.js create mode 100644 src/lib/lmsClient.js diff --git a/CLAUDE.md b/CLAUDE.md index 9f8c4ced..be1228f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -340,6 +340,17 @@ The judge form is hard-gated behind LMS training: `JudgeTrainingGate` (`src/comp Manual verification is client-side against the LMS's public Convex query `certificates:getCertificateByShareToken` (anonymous + CORS-open by design — same query the LMS's own `/certificate/:token` page uses). Note `getMyCertificates` payloads carry NO `recipientName` — the verified summary line must render only present parts. Slot matching is by regex over the cert's `quizTitle`+`targetTitle` (`/judge\s*intro/i`, `/judging\s*tool/i` in `JUDGE_TRAINING_CERTS`) — keep in sync if LMS video/quiz titles change; duplicate tokens across the two fields are rejected. Cert URLs live in formData (`judgeTrainingIntroCertUrl`/`judgeTrainingToolCertUrl`) so they autosave, hydrate from a previous submission (returning judges auto-unlock via re-verification), and submit with the application (no backend change); submit also stamps `judgeTrainingCompleted` and `handleSubmit` re-checks `trainingVerified`. Both links render as clickable links in admin `ApplicationReviewCard` (judge `secondaryFields` + `labelMap` + all three `isLink` arrays). GA: `judge_app_training_link_click`, `judge_app_training_cert_verified` (intro|tool), `judge_app_training_unlocked`, `judge_app_training_autocheck` (label=mount|refocus|manual, value=match count), `judge_app_training_autodetected` (intro|tool), `judge_app_training_autocheck_failed` (auth|network|server), `judge_app_training_manual_fallback_open`. +### Judge review in Volunteer Admin (intro video + LMS training status, Aug 2026) + +The Judges tab of the volunteer workbench (`/admin/hackathons/?section=volunteer`) reviews the PR-345 judge fields. Load-bearing pieces: + +- **`src/lib/lmsClient.js` is the shared LMS Convex client** — the transport (`lmsQuery`/`lmsMutation` with `{path, args, format:"json"}` + auth/network/server error codes), `extractCertToken`/`certUrlForToken`, `verifyCertToken` (anonymous cert lookup, module-level promise cache with rejected-promise eviction), and the moved-here constants `JUDGE_TRAINING_CERTS`/`JUDGE_TRAINING_BUNDLE_URL`. `JudgeTrainingGate.js` re-exports all its old public names (judge-application.js imports unchanged) — don't re-inline transport code into the gate. +- **`src/hooks/use-judge-training-status.js`** (+ exported `normalizeEmail`) runs two parallel passes and merges into ONE setState: (a) anonymous verification of every stored `judgeTraining*CertUrl` (works for all admins), (b) an authed rollup — `quizzes:listQuizzes` → title-regex match via `JUDGE_TRAINING_CERTS[].match` → `quizzes:getQuizResults {quizId}` ×2 + `users:listUsers` (userId→email join) — behind a module-level 60s TTL cache. **The LMS soft-degrades without `MANAGE_QUIZZES`** (`listQuizzes`→`[]`, `getQuizResults`→`null`, but `listUsers` throws), so zero matched quizzes is treated as an auth failure → `lmsAccess: "certs-only"`, never rendered as "no attempts". `lmsAccess`: `null | "full" | "certs-only" | "unavailable"`. Token-rotation pattern honored (refs + `Boolean(accessToken)` + judges fingerprint). Attempt data therefore only shows for admins whose LMS account (email-linked) has an owner/admin/editor role; localhost dev-issuer always lands in certs-only. +- **`VolunteerWorkbench`** owns the single hook call (gated `tabValue === 1` = judges) and ONE page-level video `Dialog` (`VideoDisplay`); it passes `trainingStatusByEmail`/`trainingLmsAccess`/`onPlayVideo` to BOTH `VolunteerTable` and `ApplicationReviewList`. Hook + dialog state are declared before the `!isAdmin` early return (hook-order). +- **`ApplicationReviewCard`**: judge cards render the module-scope `JudgeTrainingPanel` (LiteVideoThumbnail → workbench dialog, per-slot cert rows w/ score+issued date+attempts, `judgeTrainingCompleted` chip, training-bundle link). The video/cert URL fields were REMOVED from judge `secondaryFields` and added to the `alreadyRendered` set — don't re-add them as raw field rows. The 3 duplicated `isLink` arrays are now one module `LINK_FIELDS` constant, and the expanded secondaryFields grid passes the isLink arg (was a bug — links degraded to plain text when expanded). +- **`VolunteerTable`**: judges-only `training` + `introVideo` columns (`sortable: false`, honored in the header) via module-scope `trainingChipConfig` (also used by the mobile card view). Chip falls back to `volunteer.judgeTrainingCompleted` while LMS data is pending/unavailable. +- **Backend privacy fix**: `introductionVideoUrl` + both cert URL fields are in `PUBLIC_VOLUNTEER_DENYLIST` (`common/utils/firebase.py`) — the UNauthenticated judge route must not leak them (the form promises the video is review-team-only). Admin route unaffected. Don't remove them from the denylist. + ### Judge form — in-person is a hard gate at physical venues `judge-application.js` has an `isVirtualEvent()` helper (location contains global/virtual/online/remote — same heuristic as the volunteer form's). At physical events, `validateAvailability` **blocks** (not warns) `inPerson !== "Yes"` and `canAttendJudging === "No"` on both step-Next and submit; the availability step shows blocking error alerts that route the applicant to the mentor application (mentors can be virtual) or `/hack` online events. "Partial" judging-window attendance stays allowed. Virtual events keep the soft-warning behavior. Don't reintroduce the old "remote judging is possible" soft warning at physical events. diff --git a/src/components/ApplicationForm/JudgeTrainingGate.js b/src/components/ApplicationForm/JudgeTrainingGate.js index 678d76e1..06d4e9e0 100644 --- a/src/components/ApplicationForm/JudgeTrainingGate.js +++ b/src/components/ApplicationForm/JudgeTrainingGate.js @@ -32,139 +32,30 @@ import { warningAlertSx, } from "./refinedStyles"; -// The judge-training bundle on the OHack LMS (two videos, each with a -// knowledge check that issues a shareable certificate on a passing score). -export const JUDGE_TRAINING_BUNDLE_URL = - "https://lms.ohack.dev/bundles/kn7ect1nhxqkcn2tp32tbypzdx8ckjx6"; - -// The LMS's Convex deployment. Its Functions HTTP API is used two ways: -// - anonymously: certificates:getCertificateByShareToken verifies a pasted -// link (same query the LMS's own /certificate/:token page uses); -// - authenticated: lms.ohack.dev signs in through the SAME PropelAuth -// instance as www.ohack.dev (auth.ohack.dev, registered as a trusted -// customJwt issuer with EXTERNAL_AUTH_TRUST_EMAILS=true), so the judge's -// own accessToken can call externalAuth:ensureExternalUser and -// certificates:getMyCertificates via `Authorization: Bearer` to -// auto-detect earned certificates without any copy/paste. -// CORS is open on both. Dev caveat: localhost logs into a propelauthtest -// issuer the production LMS does not trust — authed calls fail there and the -// gate falls back to manual paste. -const LMS_CONVEX_BASE = - process.env.NEXT_PUBLIC_LMS_CONVEX_URL || - "https://majestic-trout-419.convex.cloud"; -const LMS_CONVEX_QUERY_URL = `${LMS_CONVEX_BASE}/api/query`; -const LMS_CONVEX_MUTATION_URL = `${LMS_CONVEX_BASE}/api/mutation`; +// Convex transport + cert-token helpers live in the shared LMS client (also +// used by the admin judge-review surfaces). Re-exported so this component's +// public API is unchanged for existing importers. +import { + JUDGE_TRAINING_BUNDLE_URL, + JUDGE_TRAINING_CERTS, + certUrlForToken, + extractCertToken, + lmsMutation, + lmsQuery, + verifyCertToken, +} from "../../lib/lmsClient"; + +export { JUDGE_TRAINING_BUNDLE_URL, JUDGE_TRAINING_CERTS, extractCertToken }; // Minimum gap between automatic checks (mount/refocus). The explicit // "Check again" button bypasses it. const AUTO_CHECK_THROTTLE_MS = 15000; -// Accepts a full LMS certificate URL or a bare 64-hex share token. -const CERT_TOKEN_RE = /^[0-9a-f]{64}$/i; -const CERT_URL_RE = /lms\.ohack\.dev\/certificate\/([0-9a-f]{64})/i; - -export const extractCertToken = (input) => { - const value = (input || "").trim(); - if (!value) return null; - if (CERT_TOKEN_RE.test(value)) return value.toLowerCase(); - const match = CERT_URL_RE.exec(value); - return match ? match[1].toLowerCase() : null; -}; - -const certUrlForToken = (token) => `https://lms.ohack.dev/certificate/${token}`; - -// The two required certificates. `match` runs against the certificate's -// quizTitle + targetTitle (snapshotted at issuance), so it keeps working if -// the LMS titles get lightly reworded — keep these in sync with the bundle's -// video/quiz names ("Judge Intro" / "Using the judging tool"). -export const JUDGE_TRAINING_CERTS = [ - { - field: "judgeTrainingIntroCertUrl", - key: "intro", - videoTitle: "Judge Intro", - match: /judge\s*intro/i, - }, - { - field: "judgeTrainingToolCertUrl", - key: "tool", - videoTitle: "Using the judging tool", - match: /judging\s*tool/i, - }, -]; - -const verifyCertToken = async (token) => { - const response = await fetch(LMS_CONVEX_QUERY_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - path: "certificates:getCertificateByShareToken", - args: { shareToken: token }, - format: "json", - }), - }); - if (!response.ok) { - throw new Error(`Certificate lookup failed: ${response.status}`); - } - const body = await response.json(); - if (body?.status !== "success") { - throw new Error("Certificate lookup failed"); - } - // null value = token doesn't resolve to a certificate - return body.value || null; -}; - -// Authenticated Convex function call. Throws { code: "auth"|"network"|"server" } -// so callers can tell "this login isn't trusted by the LMS" (expected in dev, -// or on an untrusted issuer) apart from transient failures. -const callLmsAuthed = async (url, path, accessToken) => { - let response; - try { - response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, - }, - body: JSON.stringify({ path, args: {}, format: "json" }), - }); - } catch (err) { - throw Object.assign(new Error(`LMS unreachable: ${err.message}`), { - code: "network", - }); - } - if (response.status === 401 || response.status === 403) { - throw Object.assign(new Error(`LMS auth rejected: ${response.status}`), { - code: "auth", - }); - } - if (!response.ok) { - throw Object.assign(new Error(`LMS call failed: ${response.status}`), { - code: "server", - }); - } - const body = await response.json(); - if (body?.status !== "success") { - const message = body?.errorMessage || "LMS call failed"; - throw Object.assign(new Error(message), { - code: /auth|unauthenticated|identity/i.test(message) ? "auth" : "server", - }); - } - return body.value; -}; - const ensureExternalUserOnLms = (accessToken) => - callLmsAuthed( - LMS_CONVEX_MUTATION_URL, - "externalAuth:ensureExternalUser", - accessToken, - ); + lmsMutation("externalAuth:ensureExternalUser", {}, accessToken); const fetchMyLmsCertificates = (accessToken) => - callLmsAuthed( - LMS_CONVEX_QUERY_URL, - "certificates:getMyCertificates", - accessToken, - ); + lmsQuery("certificates:getMyCertificates", {}, accessToken); // Assign the caller's certificates to the two required slots. Pure so it's // unit-testable: newest issuedAt wins when a quiz was passed more than once, diff --git a/src/components/admin/ApplicationReviewCard.js b/src/components/admin/ApplicationReviewCard.js index 79903bef..e0cb8107 100644 --- a/src/components/admin/ApplicationReviewCard.js +++ b/src/components/admin/ApplicationReviewCard.js @@ -33,7 +33,15 @@ import { Edit as EditIcon, Gavel as StatusIcon, OpenInNew as OpenInNewIcon, + CheckCircle as CheckCircleIcon, + Cancel as CancelIcon, + HelpOutline as HelpOutlineIcon, } from "@mui/icons-material"; +import LiteVideoThumbnail from "../VideoDisplay/LiteVideoThumbnail"; +import { + JUDGE_TRAINING_BUNDLE_URL, + JUDGE_TRAINING_CERTS, +} from "../../lib/lmsClient"; // Resolve LinkedIn URL from any of the field names forms use const getLinkedInUrl = (app) => { @@ -42,6 +50,254 @@ const getLinkedInUrl = (app) => { return raw.startsWith("http") ? raw : `https://${raw}`; }; +// Fields rendered as external links wherever they appear on the card. +const LINK_FIELDS = [ + "linkedin", + "linkedinProfile", + "linkedinUrl", + "github", + "portfolio", + "website", + "introductionVideoUrl", + "judgeTrainingIntroCertUrl", + "judgeTrainingToolCertUrl", +]; + +const TRAINING_SLOT_COPY = { + missing: "No certificate on the application", + invalid: "Malformed certificate link", + not_found: "Certificate not found on the LMS", + mismatch: "Certificate is for a different quiz", + error: "Couldn't verify — LMS unreachable", +}; + +const formatIssuedDate = (ms) => { + if (!ms) return null; + const date = new Date(ms); + return Number.isNaN(date.getTime()) ? null : date.toLocaleDateString(); +}; + +// One row of the training panel: verification result for a single required +// certificate, plus attempt data when the admin has LMS access. +const TrainingSlotRow = ({ spec, slot, lmsAccess }) => { + const state = slot?.state || "missing"; + const verified = state === "verified"; + const rollup = slot?.rollup; + const detailParts = []; + if (verified) { + if (typeof slot.cert?.score === "number") { + detailParts.push(`score ${Math.round(slot.cert.score)}%`); + } + const issued = formatIssuedDate(slot.cert?.issuedAt); + if (issued) detailParts.push(`issued ${issued}`); + } + return ( + + {verified ? ( + + ) : state === "missing" || state === "error" ? ( + + ) : ( + + )} + + + {spec.videoTitle} + {verified && detailParts.length > 0 && ( + + {` — ${detailParts.join(" · ")}`} + + )} + {slot?.certUrl && state !== "invalid" && ( + <> + {" "} + + View cert + + + )} + + {!verified && ( + + {TRAINING_SLOT_COPY[state]} + {state === "mismatch" && slot?.cert && ( + <> (it's for “{slot.cert.targetTitle || slot.cert.quizTitle}”) + )} + + )} + {lmsAccess === "full" && rollup && ( + + {`attempts ${rollup.attemptCount} · best ${Math.round(rollup.bestScore)}%`} + {rollup.passed + ? rollup.attemptsToPass + ? ` · passed on attempt ${rollup.attemptsToPass}` + : " · passed" + : " · not passed yet"} + + )} + {!verified && rollup?.passed && ( + + )} + + + ); +}; + +// Judge-only panel: intro-video thumbnail beside LMS training verification. +// Module-scope on purpose (defining it inside the card remounts it on every +// parent state tick — the SectionBlock lesson). +const JudgeTrainingPanel = ({ + application, + trainingStatus, + lmsAccess, + onPlayVideo, +}) => { + const videoUrl = application.introductionVideoUrl; + return ( + + + + Intro video + + {videoUrl ? ( + onPlayVideo ? ( + onPlayVideo(videoUrl, application.name)} + /> + ) : ( + + Watch intro video + + ) + ) : ( + + + No intro video + + + )} + + + + Judge training + + + {trainingStatus ? ( + JUDGE_TRAINING_CERTS.map((spec) => ( + + )) + ) : ( + // No verification data (hook disabled / card used standalone / + // still checking): fall back to the stored links. + + {JUDGE_TRAINING_CERTS.map((spec) => + application[spec.field] ? ( + + {spec.videoTitle}:{" "} + + View cert + + + ) : ( + + {spec.videoTitle}: no certificate on the application + + ), + )} + {lmsAccess === null && ( + + Checking LMS training status… + + )} + + )} + + {lmsAccess === "certs-only" && + "Attempt details need an LMS admin/editor account. "} + {lmsAccess === "unavailable" && + "LMS unreachable — showing stored links only. "} + + Open training bundle + + + + + ); +}; + const ApplicationReviewCard = ({ application, applicationType, @@ -49,6 +305,9 @@ const ApplicationReviewCard = ({ onReject, onEdit, isLoading = false, + trainingStatus, + lmsAccess, + onPlayVideo, }) => { const [expanded, setExpanded] = useState(false); @@ -115,6 +374,8 @@ const ApplicationReviewCard = ({ judge: { title: "Judge Application", primaryFields: ["name", "email", "title", "companyName", "status"], + // introductionVideoUrl + the training cert URLs render in the + // JudgeTrainingPanel, not as raw field rows. secondaryFields: [ "inPerson", "canAttendJudging", @@ -122,9 +383,6 @@ const ApplicationReviewCard = ({ "country", "state", "linkedinProfile", - "introductionVideoUrl", - "judgeTrainingIntroCertUrl", - "judgeTrainingToolCertUrl", "backgroundAreas", ], additionalFields: [ @@ -601,6 +859,16 @@ const ApplicationReviewCard = ({ })} + {/* Judge-only: intro video + LMS training verification */} + {applicationType === "judge" && ( + + )} + {/* Secondary information (visible when collapsed) */} {!expanded && ( @@ -609,16 +877,7 @@ const ApplicationReviewCard = ({ const value = application[field]; if (!value) return null; - const isLink = [ - "linkedin", - "github", - "portfolio", - "website", - "linkedinProfile", - "introductionVideoUrl", - "judgeTrainingIntroCertUrl", - "judgeTrainingToolCertUrl", - ].includes(field); + const isLink = LINK_FIELDS.includes(field); return ( @@ -718,7 +977,7 @@ const ApplicationReviewCard = ({ }), }} > - {renderField(field, value)} + {renderField(field, value, LINK_FIELDS.includes(field))} ); @@ -735,16 +994,7 @@ const ApplicationReviewCard = ({ const value = application[field]; if (!value) return null; - const isLink = [ - "linkedin", - "github", - "portfolio", - "website", - "linkedinProfile", - "introductionVideoUrl", - "judgeTrainingIntroCertUrl", - "judgeTrainingToolCertUrl", - ].includes(field); + const isLink = LINK_FIELDS.includes(field); return ( @@ -1314,6 +1564,11 @@ const ApplicationReviewCard = ({ "status", "timestamp", "event_id", + // rendered by JudgeTrainingPanel + "introductionVideoUrl", + "judgeTrainingIntroCertUrl", + "judgeTrainingToolCertUrl", + "judgeTrainingCompleted", // internal / audit fields never shown to reviewers "id", "user_id", @@ -1355,17 +1610,7 @@ const ApplicationReviewCard = ({ {extraEntries.map(([key, val]) => { - const isLink = [ - "linkedin", - "linkedinProfile", - "linkedinUrl", - "github", - "portfolio", - "website", - "introductionVideoUrl", - "judgeTrainingIntroCertUrl", - "judgeTrainingToolCertUrl", - ].includes(key); + const isLink = LINK_FIELDS.includes(key); const displayVal = Array.isArray(val) ? val.join(", ") : typeof val === "object" diff --git a/src/components/admin/ApplicationReviewList.js b/src/components/admin/ApplicationReviewList.js index db0ff74b..0f7d494b 100644 --- a/src/components/admin/ApplicationReviewList.js +++ b/src/components/admin/ApplicationReviewList.js @@ -31,6 +31,7 @@ import { LinkedIn as LinkedInIcon, } from '@mui/icons-material'; import ApplicationReviewCard from './ApplicationReviewCard'; +import { normalizeEmail } from '../../hooks/use-judge-training-status'; const getLinkedInUrl = (app) => { const raw = app.linkedin || app.linkedinProfile || app.linkedinUrl || ''; @@ -47,6 +48,10 @@ const ApplicationReviewList = ({ onBatchReject, isLoading = false, eventId, + // Judge training/video review (judge tab only; see useJudgeTrainingStatus) + trainingStatusByEmail, + trainingLmsAccess, + onPlayVideo, // Filter state props filter, statusFilter, @@ -670,6 +675,11 @@ const ApplicationReviewList = ({ onReject={handleReject} onEdit={onEdit} isLoading={isLoading} + trainingStatus={ + trainingStatusByEmail?.[normalizeEmail(application.email)] + } + lmsAccess={trainingLmsAccess} + onPlayVideo={onPlayVideo} /> ))} diff --git a/src/components/admin/VolunteerTable.js b/src/components/admin/VolunteerTable.js index d9555919..74f12a9d 100644 --- a/src/components/admin/VolunteerTable.js +++ b/src/components/admin/VolunteerTable.js @@ -39,10 +39,55 @@ 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, OpenInNew as OpenInNewIcon } from '@mui/icons-material'; +import { Email as EmailIcon, VolunteerActivism as CertificateIcon, OpenInNew as OpenInNewIcon, PlayCircleFilled as PlayCircleIcon } from '@mui/icons-material'; import { FaPaperPlane, FaSlack, FaLinkedin } from 'react-icons/fa'; import NextLink from 'next/link'; import HackerDepositChip from "./HackerDepositChip"; +import { JUDGE_TRAINING_CERTS } from "../../lib/lmsClient"; +import { normalizeEmail } from "../../hooks/use-judge-training-status"; + +// Compact judge-training summary for the table/mobile chip. `entry` is one +// value from useJudgeTrainingStatus's statusByEmail (undefined while the LMS +// check is pending or unavailable — then fall back to the application's own +// judgeTrainingCompleted flag). Returns { label, color, tooltip } or null. +const trainingChipConfig = (entry, volunteer, lmsAccess) => { + if (!entry) { + return volunteer.judgeTrainingCompleted + ? { + label: "✓ Trained", + color: "success", + tooltip: "Marked complete at submit (LMS check pending)", + } + : null; + } + const rows = JUDGE_TRAINING_CERTS.map((spec) => { + const slot = entry.slots?.[spec.key]; + const done = slot?.state === "verified" || slot?.rollup?.passed; + const state = slot?.state || "missing"; + let line = `${spec.videoTitle}: ${ + state === "verified" ? "verified" : state.replace(/_/g, " ") + }`; + if (state === "verified" && typeof slot?.cert?.score === "number") { + line += ` (${Math.round(slot.cert.score)}%)`; + } + if (lmsAccess === "full" && slot?.rollup) { + const rollup = slot.rollup; + line += ` — ${rollup.attemptCount} attempt${ + rollup.attemptCount === 1 ? "" : "s" + }, best ${Math.round(rollup.bestScore)}%`; + } + return { done, line }; + }); + const doneCount = rows.filter((row) => row.done).length; + const tooltip = rows.map((row) => row.line).join("\n"); + if (doneCount === rows.length) { + return { label: "✓ Trained", color: "success", tooltip }; + } + if (doneCount > 0) { + return { label: `${doneCount} of ${rows.length}`, color: "warning", tooltip }; + } + return { label: "✗ Not trained", color: "error", tooltip }; +}; const StyledTableContainer = styled(TableContainer)(({ theme }) => ({ width: "100%", @@ -206,6 +251,10 @@ const VolunteerTable = ({ // Deposit refund (hackers only) depositEnabled = false, onDepositClick, + // Judge training/video review (judges only; see useJudgeTrainingStatus) + trainingStatusByEmail, + trainingLmsAccess, + onPlayVideo, }) => { const [copyFeedback, setCopyFeedback] = useState({ open: false, message: '' }); const [resendStatuses, setResendStatuses] = useState({}); // { resend_id: { last_event, ... } } @@ -371,7 +420,6 @@ const VolunteerTable = ({ for (let i = 0; i < unique.length; i += 100) { fetchResendStatuses(unique.slice(i, i + 100)); } - // 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 @@ -428,6 +476,8 @@ const VolunteerTable = ({ ...baseColumns, { id: "checkedIn", label: "Checked In", minWidth: 80, priority: 2 }, { id: "status", label: "Status", minWidth: 90 }, // Reduced from 120 + { id: "training", label: "Training", minWidth: 90, sortable: false }, + { id: "introVideo", label: "Video", minWidth: 56, sortable: false }, { id: "title", label: "Title", minWidth: 100, priority: 2 }, // Reduced from 150 { id: "background", label: "Background", minWidth: 120, priority: 3 }, // Reduced from 150 ]; @@ -534,6 +584,50 @@ const VolunteerTable = ({ const renderCellContent = (volunteer, column) => { switch (column.id) { + case "training": { + const chip = trainingChipConfig( + trainingStatusByEmail?.[normalizeEmail(volunteer.email)], + volunteer, + trainingLmsAccess, + ); + if (!chip) { + return ( + + — + + ); + } + return ( + {chip.tooltip}} + > + + + ); + } + case "introVideo": + return volunteer.introductionVideoUrl ? ( + + + onPlayVideo?.(volunteer.introductionVideoUrl, volunteer.name) + } + > + + + + ) : ( + + — + + ); case "deposit": return ( )} + {type === 'judges' && ( + + {(() => { + const chip = trainingChipConfig( + trainingStatusByEmail?.[normalizeEmail(volunteer.email)], + volunteer, + trainingLmsAccess, + ); + return chip ? ( + {chip.tooltip}}> + + + ) : null; + })()} + {volunteer.introductionVideoUrl && onPlayVideo && ( + + onPlayVideo(volunteer.introductionVideoUrl, volunteer.name)} + > + + + + )} + + )} + {type === 'judges' && volunteer.background && ( Background: {volunteer.background.substring(0, 60)}{volunteer.background.length > 60 ? '...' : ''} @@ -1884,6 +2006,11 @@ const VolunteerTable = ({ }) }} > + {column.sortable === false ? ( + + {column.label} + + ) : ( + )} ); })} diff --git a/src/components/admin/volunteer/VolunteerWorkbench.js b/src/components/admin/volunteer/VolunteerWorkbench.js index b3e696fe..a95877ae 100644 --- a/src/components/admin/volunteer/VolunteerWorkbench.js +++ b/src/components/admin/volunteer/VolunteerWorkbench.js @@ -28,8 +28,15 @@ import { Collapse, Card, CardContent, + Dialog, + DialogTitle, + DialogContent, } from "@mui/material"; -import { Share as ShareIcon, ContentCopy as CopyIcon } from "@mui/icons-material"; +import { + Share as ShareIcon, + ContentCopy as CopyIcon, + Close as CloseIcon, +} from "@mui/icons-material"; // Import components individually to avoid circular dependencies import AdminPage from "../../../components/admin/AdminPage"; @@ -44,7 +51,9 @@ import BulkCertificateDialog from "../../../components/admin/BulkCertificateDial import HackerDepositRefundDialog from "../../../components/admin/HackerDepositRefundDialog"; import HackerDepositBulkRefundDialog from "../../../components/admin/HackerDepositBulkRefundDialog"; import { getDepositState } from "../../../components/admin/HackerDepositChip"; +import VideoDisplay from "../../../components/VideoDisplay/VideoDisplay"; import useHackathonEvents from "../../../hooks/use-hackathon-events"; +import useJudgeTrainingStatus from "../../../hooks/use-judge-training-status"; // Define initial state outside component to prevent re-initialization const INITIAL_VOLUNTEERS_STATE = { @@ -149,6 +158,9 @@ const VolunteerWorkbench = ({ userClass, embedded = false, externalEventId, onSn const [volunteersForBulkCertificate, setVolunteersForBulkCertificate] = useState([]); const [volunteerTypeForBulkCertificate, setVolunteerTypeForBulkCertificate] = useState(''); const [shareSnackbar, setShareSnackbar] = useState({ open: false, message: '' }); + // One page-level player dialog serves both the table and review views + // (TeamList pattern — never an iframe per row/card). + const [videoDialog, setVideoDialog] = useState({ open: false, url: null, name: null }); // Filter state management const [filterStates, setFilterStates] = useState({ @@ -285,6 +297,27 @@ const VolunteerWorkbench = ({ userClass, embedded = false, externalEventId, onSn initializedFromUrlRef.current = true; }, [embedded, externalEventId, selectedEventId]); + const handlePlayVideo = useCallback((url, name) => { + setVideoDialog({ open: true, url, name }); + }, []); + const handleCloseVideo = useCallback(() => { + // Keep url/name during the close animation. + setVideoDialog((prev) => ({ ...prev, open: false })); + }, []); + + // LMS training status for the Judges tab (tab 1). Gated on the tab so the + // other tabs never call the LMS; one fetch feeds both table and review + // views. (Declared before the !isAdmin early return to keep hook order + // stable.) + const { + statusByEmail: trainingStatusByEmail, + lmsAccess: trainingLmsAccess, + } = useJudgeTrainingStatus({ + accessToken, + judges: volunteers.judges, + enabled: isAdmin && tabValue === 1 && volunteers.judges.length > 0, + }); + // Early return if not admin to prevent further execution if (!isAdmin) { if (embedded) return null; @@ -1640,6 +1673,9 @@ const VolunteerWorkbench = ({ userClass, embedded = false, externalEventId, onSn orgId={orgId} depositEnabled={depositEnabled} onDepositClick={handleDepositClick} + trainingStatusByEmail={trainingStatusByEmail} + trainingLmsAccess={trainingLmsAccess} + onPlayVideo={handlePlayVideo} /> {sortedVolunteers.length === 0 && ( @@ -1661,6 +1697,9 @@ const VolunteerWorkbench = ({ userClass, embedded = false, externalEventId, onSn onBatchReject={handleBatchRejectApplications} isLoading={loading} eventId={selectedEventId} + trainingStatusByEmail={trainingStatusByEmail} + trainingLmsAccess={trainingLmsAccess} + onPlayVideo={handlePlayVideo} // Controlled filter state filter={getCurrentFilterState().filter} statusFilter={getCurrentFilterState().statusFilter} @@ -1682,6 +1721,31 @@ const VolunteerWorkbench = ({ userClass, embedded = false, externalEventId, onSn )} + {/* Intro-video player — one dialog for every row/card */} + + + {`${videoDialog.name || "Judge"} — intro video`} + + + + + + {videoDialog.url && ( + + )} + + + setEditDialogOpen(false)} diff --git a/src/hooks/use-judge-training-status.js b/src/hooks/use-judge-training-status.js new file mode 100644 index 00000000..8bbb9aaa --- /dev/null +++ b/src/hooks/use-judge-training-status.js @@ -0,0 +1,265 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + JUDGE_TRAINING_CERTS, + certUrlForToken, + extractCertToken, + lmsQuery, + verifyCertToken, +} from "../lib/lmsClient"; + +// Join key between OHack judge applications and LMS accounts (PropelAuth +// verified emails on both sides). +export const normalizeEmail = (email) => (email || "").trim().toLowerCase(); + +// Module-level cache for the authed rollup pass (listQuizzes → getQuizResults +// → listUsers). Global rather than per-event — quiz results are +// event-agnostic — and shared across mounts/tab switches so returning to the +// Judges tab within the TTL costs no LMS calls. +const ROLLUP_TTL_MS = 60 * 1000; +let rollupCache = { fetchedAt: 0, promise: null }; + +// Attempt/completion rollups keyed by normalized email: +// Map where rollup = +// { attemptCount, bestScore, passed, attemptsToPass, lastAttemptAt }. +// Requires the caller's LMS profile to carry MANAGE_QUIZZES + VIEW_USERS +// (owner/admin/editor). listQuizzes/getQuizResults soft-degrade to []/null +// without the role, so an empty quiz match is treated as an auth failure +// rather than rendered as "nobody attempted anything". +const fetchRollupsByEmail = async (accessToken) => { + const quizzes = await lmsQuery("quizzes:listQuizzes", {}, accessToken); + const slotQuizzes = JUDGE_TRAINING_CERTS.map((spec) => ({ + spec, + quiz: (quizzes || []).find((q) => + spec.match.test(`${q.title || ""} ${q.targetTitle || ""}`), + ), + })).filter((entry) => entry.quiz); + if (slotQuizzes.length === 0) { + throw Object.assign( + new Error("Judge-training quizzes not visible to this LMS account"), + { code: "auth" }, + ); + } + + const [users, ...quizResults] = await Promise.all([ + lmsQuery("users:listUsers", {}, accessToken), + ...slotQuizzes.map(({ quiz }) => + lmsQuery("quizzes:getQuizResults", { quizId: quiz._id }, accessToken), + ), + ]); + + const emailByUserId = new Map(); + (users || []).forEach((user) => { + if (user?.userId && user?.email) { + emailByUserId.set(user.userId, normalizeEmail(user.email)); + } + }); + + const byEmail = new Map(); + slotQuizzes.forEach(({ spec }, i) => { + (quizResults[i]?.results || []).forEach((row) => { + const email = emailByUserId.get(row.userId); + if (!email) return; + const entry = byEmail.get(email) || {}; + entry[spec.key] = { + attemptCount: row.attemptCount, + bestScore: row.bestScore, + passed: row.passed, + attemptsToPass: row.attemptsToPass, + lastAttemptAt: row.lastAttemptAt, + }; + byEmail.set(email, entry); + }); + }); + return byEmail; +}; + +const getRollupsCached = (accessToken, force) => { + const fresh = + rollupCache.promise && Date.now() - rollupCache.fetchedAt < ROLLUP_TTL_MS; + if (!fresh || force) { + const promise = fetchRollupsByEmail(accessToken); + rollupCache = { fetchedAt: Date.now(), promise }; + // Evict rejected promises so a later run can retry. + promise.catch(() => { + if (rollupCache.promise === promise) { + rollupCache = { fetchedAt: 0, promise: null }; + } + }); + } + return rollupCache.promise; +}; + +const resolveSlot = (spec, rawValue, certResults, rollup) => { + const raw = (rawValue || "").trim(); + const token = extractCertToken(raw); + const base = { + certUrl: token ? certUrlForToken(token) : raw || null, + cert: null, + rollup: rollup || null, + }; + if (!raw) return { ...base, state: "missing" }; + if (!token) return { ...base, state: "invalid" }; + const lookup = certResults.get(token); + if (!lookup || lookup.status === "error") return { ...base, state: "error" }; + if (!lookup.cert) return { ...base, state: "not_found" }; + const cert = lookup.cert; + if (!spec.match.test(`${cert.quizTitle || ""} ${cert.targetTitle || ""}`)) { + return { ...base, cert, state: "mismatch" }; + } + return { ...base, cert, state: "verified" }; +}; + +/** + * LMS training status for a list of judge applications, for admin review. + * + * Two passes run in parallel and merge into one state update: + * - anonymous certificate verification of each judge's stored + * judgeTraining*CertUrl (works for every admin — Convex CORS is open); + * - an authed rollup (attempt counts / best score / passed) using the + * admin's own PropelAuth token, available only when their LMS profile has + * an admin/editor role. Failure of this pass (no role, untrusted dev + * issuer, LMS down) degrades to certs-only — never an error state for the + * whole hook. + * + * Returns { statusByEmail, lmsAccess, loading, refresh }: + * - statusByEmail[normalizedEmail] = { slots: { intro, tool }, complete }, + * each slot { state, certUrl, cert, rollup } with state one of + * verified | mismatch | not_found | invalid | missing | error. + * - lmsAccess: null (not yet checked) | "full" | "certs-only" | "unavailable". + * + * Token-rotation stability (CLAUDE.md): the access token is read through a + * ref and the fetch effect keys on token PRESENCE + a judges fingerprint, so + * PropelAuth's refocus rotation never refires it. + */ +export default function useJudgeTrainingStatus({ + accessToken, + judges, + enabled = true, +}) { + const [result, setResult] = useState({ statusByEmail: {}, lmsAccess: null }); + const [loading, setLoading] = useState(false); + + const accessTokenRef = useRef(accessToken); + const judgesRef = useRef(judges); + const runIdRef = useRef(0); + + useEffect(() => { + accessTokenRef.current = accessToken; + }, [accessToken]); + useEffect(() => { + judgesRef.current = judges; + }, [judges]); + + const hasToken = Boolean(accessToken); + + // Refetch only when the judge list meaningfully changes (emails or stored + // cert tokens) — not on unrelated parent re-renders or array identity. + const fingerprint = useMemo(() => { + if (!enabled || !judges?.length) return ""; + return judges + .map((judge) => + [ + normalizeEmail(judge.email), + ...JUDGE_TRAINING_CERTS.map( + (spec) => extractCertToken(judge[spec.field]) || "", + ), + ].join("|"), + ) + .sort() + .join(";"); + }, [enabled, judges]); + + const run = useCallback(async (force = false) => { + const list = judgesRef.current || []; + if (list.length === 0) return; + const runId = ++runIdRef.current; + setLoading(true); + try { + const tokens = new Set(); + list.forEach((judge) => { + JUDGE_TRAINING_CERTS.forEach((spec) => { + const token = extractCertToken(judge[spec.field]); + if (token) tokens.add(token); + }); + }); + + // Anonymous pass — per-token failures degrade to slot state "error". + const certPromise = Promise.all( + Array.from(tokens).map((token) => + verifyCertToken(token).then( + (cert) => [token, { status: "ok", cert }], + () => [token, { status: "error", cert: null }], + ), + ), + ).then((entries) => new Map(entries)); + + // Authed rollup pass — outcome captured, never thrown. + const authToken = accessTokenRef.current; + const rollupPromise = authToken + ? getRollupsCached(authToken, force).then( + (byEmail) => ({ ok: true, byEmail }), + (err) => ({ ok: false, code: err?.code || "server" }), + ) + : Promise.resolve({ ok: false, code: "auth" }); + + const [certResults, rollupOutcome] = await Promise.all([ + certPromise, + rollupPromise, + ]); + if (runIdRef.current !== runId) return; // stale — a newer run superseded + + const rollupsByEmail = rollupOutcome.ok ? rollupOutcome.byEmail : null; + const statusByEmail = {}; + list.forEach((judge) => { + const email = normalizeEmail(judge.email); + if (!email || statusByEmail[email]) return; + const rollups = rollupsByEmail?.get(email) || null; + const slots = {}; + let complete = true; + JUDGE_TRAINING_CERTS.forEach((spec) => { + const slot = resolveSlot( + spec, + judge[spec.field], + certResults, + rollups?.[spec.key] || null, + ); + slots[spec.key] = slot; + if (!(slot.state === "verified" || slot.rollup?.passed)) { + complete = false; + } + }); + statusByEmail[email] = { slots, complete }; + }); + + let lmsAccess = "full"; + if (!rollupOutcome.ok) { + const anyCertOk = Array.from(certResults.values()).some( + (entry) => entry.status === "ok", + ); + lmsAccess = + rollupOutcome.code === "network" && !anyCertOk + ? "unavailable" + : "certs-only"; + } + + // Single state update for the whole judge list — never per judge/token. + setResult({ statusByEmail, lmsAccess }); + } finally { + if (runIdRef.current === runId) setLoading(false); + } + }, []); + + useEffect(() => { + if (!enabled || !fingerprint) return; + run(false); + }, [enabled, fingerprint, hasToken, run]); + + const refresh = useCallback((force = true) => run(force), [run]); + + return { + statusByEmail: result.statusByEmail, + lmsAccess: result.lmsAccess, + loading, + refresh, + }; +} diff --git a/src/lib/lmsClient.js b/src/lib/lmsClient.js new file mode 100644 index 00000000..c2c42abc --- /dev/null +++ b/src/lib/lmsClient.js @@ -0,0 +1,128 @@ +// Shared client for the OHack LMS's Convex Functions HTTP API +// (lms.ohack.dev). Used two ways: +// - anonymously: certificates:getCertificateByShareToken verifies a pasted +// link (same query the LMS's own /certificate/:token page uses); +// - authenticated: lms.ohack.dev signs in through the SAME PropelAuth +// instance as www.ohack.dev (auth.ohack.dev, registered as a trusted +// customJwt issuer with EXTERNAL_AUTH_TRUST_EMAILS=true), so a user's own +// accessToken can call caller-scoped queries (getMyCertificates) and — when +// their LMS profile carries an admin/editor role — admin queries like +// quizzes:getQuizResults via `Authorization: Bearer`. +// CORS is open on both. Dev caveat: localhost logs into a propelauthtest +// issuer the production LMS does not trust — authed calls fail there with +// code "auth"; anonymous calls are unaffected. + +export const LMS_CONVEX_BASE = + process.env.NEXT_PUBLIC_LMS_CONVEX_URL || + "https://majestic-trout-419.convex.cloud"; +const LMS_CONVEX_QUERY_URL = `${LMS_CONVEX_BASE}/api/query`; +const LMS_CONVEX_MUTATION_URL = `${LMS_CONVEX_BASE}/api/mutation`; + +// Accepts a full LMS certificate URL or a bare 64-hex share token. +const CERT_TOKEN_RE = /^[0-9a-f]{64}$/i; +const CERT_URL_RE = /lms\.ohack\.dev\/certificate\/([0-9a-f]{64})/i; + +export const extractCertToken = (input) => { + const value = (input || "").trim(); + if (!value) return null; + if (CERT_TOKEN_RE.test(value)) return value.toLowerCase(); + const match = CERT_URL_RE.exec(value); + return match ? match[1].toLowerCase() : null; +}; + +export const certUrlForToken = (token) => + `https://lms.ohack.dev/certificate/${token}`; + +// The judge-training bundle on the OHack LMS (two videos, each with a +// knowledge check that issues a shareable certificate on a passing score). +export const JUDGE_TRAINING_BUNDLE_URL = + "https://lms.ohack.dev/bundles/kn7ect1nhxqkcn2tp32tbypzdx8ckjx6"; + +// The two required certificates. `match` runs against the certificate's +// quizTitle + targetTitle (snapshotted at issuance) and against LMS quiz +// titles in the admin rollup, so it keeps working if the LMS titles get +// lightly reworded — keep these in sync with the bundle's video/quiz names +// ("Judge Intro" / "Using the judging tool"). +export const JUDGE_TRAINING_CERTS = [ + { + field: "judgeTrainingIntroCertUrl", + key: "intro", + videoTitle: "Judge Intro", + match: /judge\s*intro/i, + }, + { + field: "judgeTrainingToolCertUrl", + key: "tool", + videoTitle: "Using the judging tool", + match: /judging\s*tool/i, + }, +]; + +// Convex function call. Throws { code: "auth"|"network"|"server" } so callers +// can tell "this login isn't trusted by the LMS / lacks the required role" +// (expected in dev, or for admins without an LMS account) apart from +// transient failures. +const callLms = async (url, path, args, accessToken) => { + const headers = { "Content-Type": "application/json" }; + if (accessToken) headers.Authorization = `Bearer ${accessToken}`; + let response; + try { + response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({ path, args: args || {}, format: "json" }), + }); + } catch (err) { + throw Object.assign(new Error(`LMS unreachable: ${err.message}`), { + code: "network", + }); + } + if (response.status === 401 || response.status === 403) { + throw Object.assign(new Error(`LMS auth rejected: ${response.status}`), { + code: "auth", + }); + } + if (!response.ok) { + throw Object.assign(new Error(`LMS call failed: ${response.status}`), { + code: "server", + }); + } + const body = await response.json(); + if (body?.status !== "success") { + const message = body?.errorMessage || "LMS call failed"; + throw Object.assign(new Error(message), { + code: /auth|unauthenticated|identity/i.test(message) ? "auth" : "server", + }); + } + return body.value; +}; + +export const lmsQuery = (path, args = {}, accessToken = null) => + callLms(LMS_CONVEX_QUERY_URL, path, args, accessToken); + +export const lmsMutation = (path, args = {}, accessToken = null) => + callLms(LMS_CONVEX_MUTATION_URL, path, args, accessToken); + +// Anonymous certificate lookup, promise-cached module-wide: concurrent +// callers share one in-flight request per token, and settled successes stay +// cached for the page's lifetime (certs are immutable once issued). Rejected +// promises are evicted so a retry can succeed. +const certLookupCache = new Map(); // token -> Promise + +export const verifyCertToken = (token) => { + const key = (token || "").toLowerCase(); + const cached = certLookupCache.get(key); + if (cached) return cached; + const promise = lmsQuery("certificates:getCertificateByShareToken", { + shareToken: key, + }).then( + // null value = token doesn't resolve to a certificate + (value) => value || null, + (err) => { + certLookupCache.delete(key); + throw err; + }, + ); + certLookupCache.set(key, promise); + return promise; +}; From 232789e129417430770ee3a45162e6a80b79d874 Mon Sep 17 00:00:00 2001 From: Greg V <6913307+gregv@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:28:46 -0700 Subject: [PATCH 17/19] Admin Email tab: top-level email UI, Resend broadcasts + batch sends, inactive-Slack toggle - /admin/communication now has three tabs (templates | email | social); the Email Communication UI is extracted from SocialMediaManagement into EmailCommunication.js and no longer blocks on social credential validation - Slack picker defaults to active_days=365 with an "Include inactive accounts" toggle (deleted/disabled accounts stay excluded server-side) - Broadcast mode: source picker (registered users / leads / Slack / event volunteers / contact_submissions by inquiry type / pasted emails), preview counts, standing-segment sync with polled progress, compose with placeholder lint, draft/send/schedule, status panel, and a contact manager with quota bar + prune actions (unsubscribed / selected / all) - Mass personalized sends now go through the backend Resend Batch endpoint in chunks of 100 (email-only recipients; QR messages and registered users keep the per-recipient path, incl. its Slack DM side effect) - Fix: Slack-sourced recipients were misrouted to /api/admin/{slackId}/message - Fix: BatchEmailDialog toasts were silently swallowed (no SnackbarProvider); it now accepts the page's onSnack Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 17 +- src/components/admin/BatchEmailDialog.js | 14 +- src/components/admin/EmailCommunication.js | 1046 +++++++++++++++++ src/components/admin/SocialMediaManagement.js | 859 +------------- .../admin/broadcast/BroadcastComposer.js | 779 ++++++++++++ .../admin/broadcast/BroadcastSourcePicker.js | 304 +++++ .../admin/broadcast/BroadcastStatusPanel.js | 146 +++ .../admin/broadcast/ContactManagerPanel.js | 452 +++++++ src/lib/batchEmailService.js | 251 +++- src/lib/broadcastService.js | 125 ++ src/lib/emailParsing.js | 84 ++ src/pages/admin/communication/index.js | 17 +- 12 files changed, 3186 insertions(+), 908 deletions(-) create mode 100644 src/components/admin/EmailCommunication.js create mode 100644 src/components/admin/broadcast/BroadcastComposer.js create mode 100644 src/components/admin/broadcast/BroadcastSourcePicker.js create mode 100644 src/components/admin/broadcast/BroadcastStatusPanel.js create mode 100644 src/components/admin/broadcast/ContactManagerPanel.js create mode 100644 src/lib/broadcastService.js create mode 100644 src/lib/emailParsing.js diff --git a/CLAUDE.md b/CLAUDE.md index be1228f1..8009e9cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -135,7 +135,22 @@ Load-bearing details: ## Admin Communication (`/admin/communication`) + DB-backed Email Templates -`/admin/social-media` is now a redirect stub → `/admin/communication?tab=social`. The Communication page (`src/pages/admin/communication/index.js`) has two tabs (`?tab=templates|social`, shallow-synced): the new `EmailTemplateManager` and the existing `SocialMediaManagement` (unchanged, embeds cleanly — it has no router deps). The old social-media page had a Rules-of-Hooks violation (`useCallback` after a conditional return) — fixed in the new page; don't reintroduce early returns above hooks there. +`/admin/social-media` is now a redirect stub → `/admin/communication?tab=social`. The Communication page (`src/pages/admin/communication/index.js`) has THREE tabs (`?tab=templates|email|social`, shallow-synced by SLUG lookup — appending slugs never breaks deep links): `EmailTemplateManager`, `EmailCommunication` (Aug 2026 — extracted from SocialMediaManagement, see below), and `SocialMediaManagement` (now social-only: platform status, adhoc Slack message, Threads/news posting; its internal sub-tabs and all email state/JSX were removed, and its loading gate no longer blocks the email UI while social credentials validate). The old social-media page had a Rules-of-Hooks violation (`useCallback` after a conditional return) — fixed in the new page; don't reintroduce early returns above hooks there. + +### Email tab (`src/components/admin/EmailCommunication.js`, Aug 2026) + +Props `{accessToken, orgId, onSnack}` (EmailTemplateManager convention — no `useAuthInfo` inside). Two modes via a `ToggleButtonGroup`: + +- **Personalized / small batch** (the extracted legacy flow): Slack-user picker + paste/CSV custom emails → `BatchEmailDialog`. New **"Include inactive accounts" toggle** — default fetch is `active_days=365`, toggle → `10000` (disabled/deleted/bot Slack accounts are ALWAYS excluded server-side; "inactive" = no Slack profile-record update in the window). Refetch on toggle prunes selections to visible users with an info snackbar (what-you-see-is-what-you-send). Slack-sourced recipients are tagged `source: "slack"` in `getSelectedUsers()` — **load-bearing**: `batchEmailService` routes email-only sources (`custom|csv|slack`) away from `/api/admin/{id}/message` (Slack IDs aren't user-doc ids; that was a silent misroute) and through the batch path. +- **Broadcast via Resend** (`src/components/admin/broadcast/{BroadcastComposer,BroadcastSourcePicker,BroadcastStatusPanel}.js` + `src/lib/broadcastService.js`): 4-step Stepper — sources (registered users / leads / Slack w/ inactive toggle / event volunteers by type+event+approved-only / contact-form submissions filtered by inquiry type [multi-select; `INQUIRY_TYPE_OPTIONS` in `BroadcastSourcePicker.js` — keep in sync with `src/pages/contact/index.js` `INQUIRY_TYPES`] + optional `receiveUpdates`-opt-in-only / pasted emails) → preview counts (`POST /api/admin/broadcasts/preview`) → pick/create a **standing segment** (freeSolo Autocomplete; "Everyone" exists) and sync contacts (background job, 3s polling of `sync-status`; 409 `already_running` → just watch; `stalled` → safe retry) → compose (optional template seed with a **placeholder lint** — `[PLACEHOLDER]`s are NOT substituted in broadcasts, ack checkbox required; Resend merge tags like `{{{FIRST_NAME|there}}}` work) → confirm + Save-as-draft / Send-now / schedule. **Cost guardrail**: Resend bills marketing by CONTACT count (OHack is on the FREE 1,000-contact marketing tier as of Aug 2026; 5k=$40/mo) — the UI warns when a preview/sync exceeds `contact_limit` from the backend (`RESEND_MARKETING_CONTACT_LIMIT`). Unsubscribes are handled by Resend automatically; segment size ≠ delivered count. +- **Contact manager** (`broadcast/ContactManagerPanel.js`, bottom of Broadcast mode): quota bar (total vs `contact_limit`), searchable contact table (render-capped at 200 rows), and quota-reclaim deletes — "Delete unsubscribed (N)" (the safe first lever: unsubscribed contacts can't receive broadcasts but STILL count against quota), "Delete selected", and a typed-DELETE-confirm "Delete ALL" (warns it erases unsubscribe preferences). Deletes are GLOBAL Resend contacts (that's what frees quota), run as one background job at a time (`POST /api/admin/broadcasts/contacts/prune`, modes `unsubscribed|emails|all`, polled via `prune-status`; contacts listed via `GET /api/admin/broadcasts/contacts`, 60s server cache, `?force=true` to bust). +- Shared email/Slack-token parsing utils moved to `src/lib/emailParsing.js` (`validateEmail`, `parseEmailsFromText`, `parseCsvFile`, `normalizeSlackLookupToken`, `parseSlackLookupInput`) — used by both modes; don't re-inline them. +- `BatchEmailDialog` accepts an optional `onSnack` prop — there is **no SnackbarProvider in src/**, so its notistack calls are silently swallowed unless the page's snackbar is threaded through (VolunteerWorkbench still uses the notistack default, unchanged). +- GA (ADMIN category): `admin_email_batch_sent`, `admin_email_inactive_toggle`, `broadcast_preview`, `broadcast_sync_started/_completed`, `broadcast_template_seeded`, `broadcast_created_draft`, `broadcast_sent`. + +### Mass-send architecture (Aug 2026 — no more one-request-per-recipient) + +`src/lib/batchEmailService.js` `sendBatchEmails()` now partitions recipients: **email-only** recipients (source `custom|csv|slack`) go through `POST /api/admin/broadcasts/batch-send` in chunks of ≤100 (server-side `resend.Batch.send`, transactional quota) unless the message contains a `[QRCode:...]` marker (Batch has no attachments → those and any 404-from-older-backend fall back to the per-recipient `/api/admin/email/send` worker pool, MAX_PARALLEL_SENDS=8); **registered users** keep `/api/admin/{id}/message` per-recipient (it also Slack-DMs them — don't collapse that into batch). `onProgress` + `{results, summary}` contracts are preserved so `BatchEmailDialog`/VolunteerWorkbench retry-failed still works. **Email templates live in Firestore now** (collection `email_templates`, doc id = template slug) with an append-only `versions` subcollection for history. Backend: `services/email_templates_service.py` + a dedicated blueprint `api/email_templates/email_templates_views.py` (NOT messages_views — that file is frozen per backend CLAUDE.md) serving `/api/admin/templates` (GET list / POST create / PATCH / DELETE / GET `/versions` / POST `/revert` / POST `seed`), all `volunteer.admin`-gated. Versioning rules: content edits bump `version` and append a snapshot; status-only patches don't bump; **revert never rewrites history** — it copies the old version's content forward as a new version with change_note "Reverted to version N". Auto-seeds from `services/email_templates_seed.py` on first list call; `POST /seed` ("Restore defaults" button) re-inserts missing seed templates only, never overwrites edits. diff --git a/src/components/admin/BatchEmailDialog.js b/src/components/admin/BatchEmailDialog.js index c59576d0..f248d981 100644 --- a/src/components/admin/BatchEmailDialog.js +++ b/src/components/admin/BatchEmailDialog.js @@ -133,8 +133,19 @@ const BatchEmailDialog = ({ eventId, onComplete, isSelectedUsers = true, // true for selected/approved users, false for not-selected/rejected users + onSnack, // optional page-level snackbar; there is no SnackbarProvider in the app, so notistack calls are silently swallowed without this }) => { - const { enqueueSnackbar } = useSnackbar(); + const { enqueueSnackbar: notistackEnqueue } = useSnackbar(); + const enqueueSnackbar = React.useCallback( + (message, options = {}) => { + if (onSnack) { + onSnack(message, options.variant || "info"); + } else { + notistackEnqueue(message, options); + } + }, + [onSnack, notistackEnqueue], + ); const [currentStep, setCurrentStep] = useState(0); const [selectedTemplate, setSelectedTemplate] = useState(null); const [customMessage, setCustomMessage] = useState(false); @@ -262,7 +273,6 @@ const BatchEmailDialog = ({ } // messageText intentionally omitted: the untouched-guard reads it via // closure and re-running this effect per keystroke would be wasteful. - // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, isSelectedUsers, eventId, volunteerType, recipientType, templates]); const handleTemplateSelect = (template) => { diff --git a/src/components/admin/EmailCommunication.js b/src/components/admin/EmailCommunication.js new file mode 100644 index 00000000..09941430 --- /dev/null +++ b/src/components/admin/EmailCommunication.js @@ -0,0 +1,1046 @@ +import React, { useState, useEffect, useCallback } from "react"; +import { + Box, + Paper, + Typography, + Button, + Grid, + Chip, + Alert, + CircularProgress, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + Switch, + FormControlLabel, + Divider, + List, + ListItem, + ListItemText, + ListItemIcon, + Tooltip, +} from "@mui/material"; +import { + Refresh as RefreshIcon, + Email as EmailIcon, + Group as GroupIcon, + Campaign as CampaignIcon, +} from "@mui/icons-material"; +import { ToggleButton, ToggleButtonGroup } from "@mui/material"; +import axios from "axios"; +import { useEnv } from "../../context/env.context"; +import BatchEmailDialog from "./BatchEmailDialog"; +import BroadcastComposer from "./broadcast/BroadcastComposer"; +import BroadcastStatusPanel from "./broadcast/BroadcastStatusPanel"; +import ContactManagerPanel from "./broadcast/ContactManagerPanel"; +import * as ga from "../../lib/ga"; +import { + parseEmailsFromText, + normalizeSlackLookupToken, + parseSlackLookupInput, + parseCsvFile, +} from "../../lib/emailParsing"; + +// active_days sent to /api/slack/admin/users/active. "Inactive" = no Slack +// profile-record update in this window; deleted/disabled/bot accounts are +// always excluded server-side regardless. +const ACTIVE_DAYS_DEFAULT = 365; +const ACTIVE_DAYS_ALL = 10000; + +const EmailCommunication = ({ accessToken, orgId, onSnack }) => { + const { apiServerUrl } = useEnv(); + + // "personalized" = per-recipient sends with [PLACEHOLDER] support; + // "broadcast" = one Resend campaign to a synced segment. + const [mode, setMode] = useState("personalized"); + const [broadcastRefreshToken, setBroadcastRefreshToken] = useState(0); + + const [slackUsers, setSlackUsers] = useState([]); + const [loadingSlackUsers, setLoadingSlackUsers] = useState(true); + const [includeInactive, setIncludeInactive] = useState(false); + const [batchEmailDialog, setBatchEmailDialog] = useState(false); + const [emailResults, setEmailResults] = useState(null); + + // Additional recipients state + const [additionalEmails, setAdditionalEmails] = useState([]); + const [emailInput, setEmailInput] = useState(""); + const [csvFile, setCsvFile] = useState(null); + const [processingEmails, setProcessingEmails] = useState(false); + + // Selection state + const [selectedSlackUsers, setSelectedSlackUsers] = useState(new Set()); + const [slackSearchFilter, setSlackSearchFilter] = useState(""); + const [showSlackBrowser, setShowSlackBrowser] = useState(false); + const [slackPasteInput, setSlackPasteInput] = useState(""); + const [slackPasteFeedback, setSlackPasteFeedback] = useState(null); + + const fetchActiveSlackUsers = useCallback(async () => { + if (!apiServerUrl || !accessToken) return; + + const activeDays = includeInactive ? ACTIVE_DAYS_ALL : ACTIVE_DAYS_DEFAULT; + + try { + setLoadingSlackUsers(true); + const response = await axios.get( + `${apiServerUrl}/api/slack/admin/users/active?active_days=${activeDays}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "X-Org-Id": orgId, + }, + }, + ); + + if (response.data && response.data.users) { + const activeUsers = response.data.users.map((user) => ({ + id: user.id, + name: user.name, + real_name: user.real_name, + email: user.email, + tz: user.tz, + isSelected: false, // Start with no one selected + })); + setSlackUsers(activeUsers); + // Keep selections only for users still visible under the new filter. + setSelectedSlackUsers((prev) => { + if (prev.size === 0) return prev; + const visible = new Set(activeUsers.map((u) => u.id)); + const kept = new Set([...prev].filter((id) => visible.has(id))); + if (kept.size < prev.size) { + onSnack?.( + `${prev.size - kept.size} selected user(s) are hidden by the activity filter and were removed from the selection`, + "info", + ); + } + return kept.size === prev.size ? prev : kept; + }); + onSnack?.(`Loaded ${activeUsers.length} Slack users`, "success"); + } else { + onSnack?.("Failed to fetch Slack users", "error"); + } + } catch (error) { + console.error("Error fetching active Slack users:", error); + onSnack?.("Failed to fetch active Slack users", "error"); + } finally { + setLoadingSlackUsers(false); + } + }, [apiServerUrl, accessToken, orgId, includeInactive, onSnack]); + + // Fetch on mount and whenever the inactive toggle flips. Broadcast mode + // builds its lists server-side, so skip the Slack crawl there. + useEffect(() => { + if (mode !== "personalized") return; + fetchActiveSlackUsers(); + }, [fetchActiveSlackUsers, mode]); + + const handleToggleIncludeInactive = (event) => { + setIncludeInactive(event.target.checked); + ga.trackStructuredEvent( + ga.EventCategory.ADMIN, + "admin_email_inactive_toggle", + event.target.checked ? "include" : "exclude", + ); + }; + + const handleEmailComplete = (summary) => { + setEmailResults(summary); + ga.trackStructuredEvent( + ga.EventCategory.ADMIN, + "admin_email_batch_sent", + "community", + summary.successful, + ); + onSnack?.( + `Email batch complete: ${summary.successful}/${summary.total} successful`, + summary.successful === summary.total ? "success" : "warning", + ); + }; + + // Handle email input processing + const handleAddEmails = async () => { + if (!emailInput.trim() && !csvFile) return; + + setProcessingEmails(true); + + try { + let newEmails = []; + + if (emailInput.trim()) { + newEmails = parseEmailsFromText(emailInput); + } + + if (csvFile) { + const csvEmails = await parseCsvFile(csvFile); + newEmails = [...new Set([...newEmails, ...csvEmails])]; + } + + if (newEmails.length === 0) { + onSnack?.("No valid emails found", "warning"); + return; + } + + // Convert to user objects + const emailUsers = newEmails.map((email, index) => ({ + id: `custom_${Date.now()}_${index}`, + name: email.split("@")[0], // Use email prefix as name + real_name: email.split("@")[0], + email: email, + isSelected: true, + source: "custom", + })); + + const existingEmails = new Set([ + ...slackUsers.map((u) => u.email), + ...additionalEmails.map((u) => u.email), + ]); + const uniqueNewEmails = emailUsers.filter( + (user) => !existingEmails.has(user.email), + ); + + setAdditionalEmails((prev) => [...prev, ...uniqueNewEmails]); + setEmailInput(""); + setCsvFile(null); + + onSnack?.( + `Added ${uniqueNewEmails.length} new emails (${newEmails.length - uniqueNewEmails.length} duplicates skipped)`, + "success", + ); + } catch (error) { + console.error("Error processing emails:", error); + onSnack?.("Error processing emails: " + error.message, "error"); + } finally { + setProcessingEmails(false); + } + }; + + // Remove custom email + const handleRemoveCustomEmail = (emailToRemove) => { + setAdditionalEmails((prev) => + prev.filter((user) => user.email !== emailToRemove), + ); + }; + + // Clear all custom emails + const handleClearCustomEmails = () => { + setAdditionalEmails([]); + setEmailInput(""); + setCsvFile(null); + }; + + // Selection management functions + const toggleSlackUserSelection = (userId) => { + setSelectedSlackUsers((prev) => { + const newSet = new Set(prev); + if (newSet.has(userId)) { + newSet.delete(userId); + } else { + newSet.add(userId); + } + return newSet; + }); + }; + + const selectAllSlackUsers = () => { + const filteredUsers = getFilteredSlackUsers(); + setSelectedSlackUsers(new Set(filteredUsers.map((u) => u.id))); + }; + + const deselectAllSlackUsers = () => { + setSelectedSlackUsers(new Set()); + }; + + const getFilteredSlackUsers = () => { + if (!slackSearchFilter.trim()) return slackUsers.filter((u) => u.email); + + const searchTerm = slackSearchFilter.toLowerCase(); + return slackUsers.filter( + (user) => + user.email && + (user.name?.toLowerCase().includes(searchTerm) || + user.real_name?.toLowerCase().includes(searchTerm) || + user.email?.toLowerCase().includes(searchTerm)), + ); + }; + + const handlePasteSelectSlackUsers = () => { + const rawTokens = parseSlackLookupInput(slackPasteInput); + + if (rawTokens.length === 0) { + onSnack?.( + "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) { + onSnack?.("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`); + } + + onSnack?.( + messageParts.join(" · "), + unmatchedTokens.length > 0 || ambiguousTokens.length > 0 + ? "warning" + : "success", + ); + }; + + const getSelectedUsers = () => { + const selectedSlack = slackUsers + .filter((user) => selectedSlackUsers.has(user.id) && user.email) + .map((user) => ({ + ...user, + isSelected: true, // Mark as selected for BatchEmailDialog + // Slack IDs are not volunteer doc ids — this routes sends down the + // email-only path in batchEmailService instead of /api/admin/{id}/message. + source: "slack", + })); + return [...selectedSlack, ...additionalEmails]; + }; + + const getSelectedUsersCount = () => { + return selectedSlackUsers.size + additionalEmails.length; + }; + + const inactiveToggle = ( + + + } + label="Include inactive accounts" + /> + + ); + + return ( + + + { + if (next) setMode(next); + }} + size="small" + color="primary" + > + + + Personalized / small batch + + + + Broadcast via Resend + + + + {mode === "personalized" + ? "One email per recipient — supports [PLACEHOLDER] templates, QR codes, and per-volunteer send tracking. Best for targeted sends." + : "One campaign to a stored Resend contact list — automatic unsubscribe handling, unlimited sends within your contact tier. Best for newsletters and mass announcements."} + + + + {mode === "broadcast" && ( + <> + setBroadcastRefreshToken((t) => t + 1)} + /> + + + + )} + + {mode === "personalized" && ( + <> + + + + + Email Communication to Slack Community + + + {inactiveToggle} + + + + + + {loadingSlackUsers ? ( + + + + ) : ( + <> + + {includeInactive + ? `Showing all accounts (${slackUsers.length})` + : `Showing users active in the last year (${slackUsers.length})`} + + + {/* Selection Summary */} + + + 📧 Selected Recipients ({getSelectedUsersCount()}) + + + {getSelectedUsersCount() === 0 ? ( + + + No recipients selected. Choose from + Slack community members or add custom email addresses to + get started. + + + ) : ( + + {selectedSlackUsers.size > 0 && ( + } + /> + )} + {additionalEmails.length > 0 && ( + } + /> + )} + } + /> + + )} + + + {/* Action Buttons */} + + + + {getSelectedUsersCount() > 0 && ( + + )} + + {(selectedSlackUsers.size > 0 || + additionalEmails.length > 0) && ( + + )} + + + {slackUsers.length === 0 && ( + + No active Slack users found. Try refreshing, including + inactive accounts, or check your Slack integration. + + )} + + {emailResults && ( + + + Last email batch: {emailResults.successful}/ + {emailResults.total} successful + {emailResults.failed > 0 && + ` (${emailResults.failed} failed)`} + + + )} + + )} + + + {/* Additional Recipients Section */} + + + + Additional Recipients + + + Add custom email addresses beyond the Slack community. You can + copy/paste emails or upload a CSV file. + + + + {/* Email Input Section */} + + + Copy/Paste Emails + + setEmailInput(e.target.value)} + variant="outlined" + disabled={processingEmails} + sx={{ mb: 2 }} + /> + + + {/* CSV Upload Section */} + + + Upload CSV File + + + setCsvFile(e.target.files[0])} + disabled={processingEmails} + /> + + {csvFile ? ( + + ✓ {csvFile.name} ({(csvFile.size / 1024).toFixed(1)} KB) + + ) : ( + + CSV with emails in any column + + )} + + + + + {/* Action Buttons */} + + + {additionalEmails.length > 0 && ( + + )} + + + {/* Custom Emails Display */} + {additionalEmails.length > 0 && ( + <> + + + Custom Recipients ({additionalEmails.length}): + + + + {additionalEmails.map((user) => ( + + handleRemoveCustomEmail(user.email)} + color="secondary" + variant="outlined" + /> + + ))} + + + + )} + + + {/* Slack User Browser Dialog */} + setShowSlackBrowser(false)} + maxWidth="md" + fullWidth + PaperProps={{ sx: { height: "80vh" } }} + > + + + + + Browse Slack Users + + + {inactiveToggle} + + {selectedSlackUsers.size} of{" "} + {getFilteredSlackUsers().length} selected + + + + + + + {/* Search and Bulk Actions */} + + setSlackSearchFilter(e.target.value)} + size="small" + sx={{ flexGrow: 1, minWidth: 250 }} + InputProps={{ + startAdornment: 🔍, + }} + /> + + + + + + + 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 ? ( + + + {slackSearchFilter.trim() + ? "No users match your search." + : "No users with email addresses found."} + + + ) : ( + + {getFilteredSlackUsers().map((user) => ( + + + + toggleSlackUserSelection(user.id) + } + /> + } + label="" + sx={{ m: 0 }} + /> + + + + ))} + + )} + + + {/* Selection Summary */} + {selectedSlackUsers.size > 0 && ( + + + ✓ {selectedSlackUsers.size} Slack users + selected for email delivery + + + )} + + + + + + + + + {/* Batch Email Dialog */} + setBatchEmailDialog(false)} + volunteers={getSelectedUsers()} + volunteerType="community members" + accessToken={accessToken} + orgId={orgId} + eventId={null} + onComplete={handleEmailComplete} + isSelectedUsers={true} + onSnack={onSnack} + /> + + )} + + ); +}; + +export default EmailCommunication; diff --git a/src/components/admin/SocialMediaManagement.js b/src/components/admin/SocialMediaManagement.js index b710e544..afdf279f 100644 --- a/src/components/admin/SocialMediaManagement.js +++ b/src/components/admin/SocialMediaManagement.js @@ -26,14 +26,10 @@ import { Accordion, AccordionSummary, AccordionDetails, - IconButton, - Tooltip, Select, MenuItem, InputLabel, - FormControl, - Tabs, - Tab + FormControl } from '@mui/material'; import { Send as SendIcon, @@ -44,17 +40,12 @@ import { Info as InfoIcon, ExpandMore as ExpandMoreIcon, Preview as PreviewIcon, - History as HistoryIcon, - Email as EmailIcon, - Share as ShareIcon, - Group as GroupIcon + History as HistoryIcon } from '@mui/icons-material'; import { useEnv } from '../../context/env.context'; import { SocialMediaManager } from '../../lib/social-media/SocialMediaManager'; import { SUPPORTED_PLATFORMS } from '../../lib/social-media/index'; import { useAuthInfo } from '@propelauth/react'; -import BatchEmailDialog from './BatchEmailDialog'; -import axios from 'axios'; const SocialMediaManagement = ({ onSnackbar }) => { const { accessToken, userClass } = useAuthInfo(); @@ -74,26 +65,6 @@ const SocialMediaManagement = ({ onSnackbar }) => { sending: false }); - // Email functionality state - const [currentTab, setCurrentTab] = useState(0); - const [slackUsers, setSlackUsers] = useState([]); - const [loadingSlackUsers, setLoadingSlackUsers] = useState(true); - const [batchEmailDialog, setBatchEmailDialog] = useState(false); - const [emailResults, setEmailResults] = useState(null); - - // Additional recipients state - const [additionalEmails, setAdditionalEmails] = useState([]); - const [emailInput, setEmailInput] = useState(''); - const [csvFile, setCsvFile] = useState(null); - const [processingEmails, setProcessingEmails] = useState(false); - - // Selection state - 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({ dryRun: true, @@ -103,50 +74,6 @@ const SocialMediaManagement = ({ onSnackbar }) => { slackChannel: 'general' }); - // Fetch active Slack users for email functionality - const fetchActiveSlackUsers = useCallback(async () => { - if (!apiServerUrl || !accessToken) return; - - try { - setLoadingSlackUsers(true); - const org = userClass?.getOrgByName("Opportunity Hack Org"); - const orgId = org?.orgId; - - const response = await axios.get( - `${apiServerUrl}/api/slack/admin/users/active?active_days=10000`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - "X-Org-Id": orgId, - }, - } - ); - - if (response.data && response.data.users) { - const activeUsers = response.data.users - .filter(user => !user.deleted && !user.is_bot) - .map(user => ({ - id: user.id, - name: user.name, - real_name: user.real_name, - email: user.email, - tz: user.tz, - isSelected: false // Start with no one selected - })); - setSlackUsers(activeUsers); - onSnackbar?.(`Loaded ${activeUsers.length} active Slack users`, 'success'); - } else { - onSnackbar?.('Failed to fetch Slack users', 'error'); - } - } catch (error) { - console.error('Error fetching active Slack users:', error); - onSnackbar?.('Failed to fetch active Slack users', 'error'); - } finally { - setLoadingSlackUsers(false); - } - }, [apiServerUrl, accessToken, userClass]); - // Fetch news from backend const fetchNews = useCallback(async (socialMediaManager) => { if (!socialMediaManager) return; @@ -210,11 +137,6 @@ const SocialMediaManagement = ({ onSnackbar }) => { } }, [manager]); // Only depend on manager, not fetchNews - // Fetch Slack users when component mounts - useEffect(() => { - fetchActiveSlackUsers(); - }, [fetchActiveSlackUsers]); - // Post news to social media const handlePostNews = async () => { if (!manager || newsItems.length === 0) return; @@ -308,307 +230,6 @@ const SocialMediaManagement = ({ onSnackbar }) => { } }; - // Handle tab change - const handleTabChange = (event, newValue) => { - setCurrentTab(newValue); - }; - - // Handle batch email completion - const handleEmailComplete = (summary) => { - setEmailResults(summary); - onSnackbar?.(`Email batch complete: ${summary.successful}/${summary.total} successful`, summary.successful === summary.total ? 'success' : 'warning'); - }; - - // Get orgId for email functionality - const getOrgId = () => { - const org = userClass?.getOrgByName("Opportunity Hack Org"); - return org?.orgId; - }; - - // Email parsing and validation utilities - const validateEmail = (email) => { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - return emailRegex.test(email.trim()); - }; - - const parseEmailsFromText = (text) => { - if (!text) return []; - - // Split by common delimiters: comma, semicolon, space, newline - const emails = text - .split(/[,;\s\n]+/) - .map(email => email.trim()) - .filter(email => email.length > 0) - .filter(validateEmail); - - 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(); - reader.onload = (e) => { - try { - const text = e.target.result; - const lines = text.split('\n'); - const emails = []; - - lines.forEach(line => { - // Split by comma and extract potential emails - const fields = line.split(',').map(field => field.trim().replace(/[\"']/g, '')); - fields.forEach(field => { - if (validateEmail(field)) { - emails.push(field); - } - }); - }); - - resolve([...new Set(emails)]); // Remove duplicates - } catch (error) { - reject(error); - } - }; - reader.onerror = () => reject(new Error('Failed to read file')); - reader.readAsText(file); - }); - }; - - // Handle email input processing - const handleAddEmails = async () => { - if (!emailInput.trim() && !csvFile) return; - - setProcessingEmails(true); - - try { - let newEmails = []; - - if (emailInput.trim()) { - newEmails = parseEmailsFromText(emailInput); - } - - if (csvFile) { - const csvEmails = await parseCsvFile(csvFile); - newEmails = [...new Set([...newEmails, ...csvEmails])]; - } - - if (newEmails.length === 0) { - onSnackbar?.('No valid emails found', 'warning'); - return; - } - - // Convert to user objects - const emailUsers = newEmails.map((email, index) => ({ - id: `custom_${Date.now()}_${index}`, - name: email.split('@')[0], // Use email prefix as name - real_name: email.split('@')[0], - email: email, - isSelected: true, - source: 'custom' - })); - - const existingEmails = new Set([...slackUsers.map(u => u.email), ...additionalEmails.map(u => u.email)]); - const uniqueNewEmails = emailUsers.filter(user => !existingEmails.has(user.email)); - - setAdditionalEmails(prev => [...prev, ...uniqueNewEmails]); - setEmailInput(''); - setCsvFile(null); - - onSnackbar?.(`Added ${uniqueNewEmails.length} new emails (${newEmails.length - uniqueNewEmails.length} duplicates skipped)`, 'success'); - } catch (error) { - console.error('Error processing emails:', error); - onSnackbar?.('Error processing emails: ' + error.message, 'error'); - } finally { - setProcessingEmails(false); - } - }; - - // Remove custom email - const handleRemoveCustomEmail = (emailToRemove) => { - setAdditionalEmails(prev => prev.filter(user => user.email !== emailToRemove)); - }; - - // Clear all custom emails - const handleClearCustomEmails = () => { - setAdditionalEmails([]); - setEmailInput(''); - setCsvFile(null); - }; - - // Selection management functions - const toggleSlackUserSelection = (userId) => { - setSelectedSlackUsers(prev => { - const newSet = new Set(prev); - if (newSet.has(userId)) { - newSet.delete(userId); - } else { - newSet.add(userId); - } - return newSet; - }); - }; - - const selectAllSlackUsers = () => { - const filteredUsers = getFilteredSlackUsers(); - setSelectedSlackUsers(new Set(filteredUsers.map(u => u.id))); - }; - - const deselectAllSlackUsers = () => { - setSelectedSlackUsers(new Set()); - }; - - const getFilteredSlackUsers = () => { - if (!slackSearchFilter.trim()) return slackUsers.filter(u => u.email); - - const searchTerm = slackSearchFilter.toLowerCase(); - return slackUsers.filter(user => - user.email && - ( - user.name?.toLowerCase().includes(searchTerm) || - user.real_name?.toLowerCase().includes(searchTerm) || - user.email?.toLowerCase().includes(searchTerm) - ) - ); - }; - - 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) - .map(user => ({ - ...user, - isSelected: true // Mark as selected for BatchEmailDialog - })); - return [...selectedSlack, ...additionalEmails]; - }; - - const getSelectedUsersCount = () => { - return selectedSlackUsers.size + additionalEmails.length; - }; - const getPlatformStatusColor = (status) => { if (!status) return 'default'; return status.valid ? 'success' : 'error'; @@ -629,26 +250,6 @@ const SocialMediaManagement = ({ onSnackbar }) => { return ( - {/* Tab Navigation */} - - - } - label="Social Media" - id="tab-0" - aria-controls="tabpanel-0" - /> - } - label="Email Communication" - id="tab-1" - aria-controls="tabpanel-1" - /> - - - - {/* Tab Panel 0: Social Media */} -