diff --git a/CLAUDE.md b/CLAUDE.md index 11957f9a..7fd9dc78 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")`. @@ -131,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. @@ -315,7 +334,7 @@ Self-service page where a volunteer answers a branching checklist that _picks_ o **Top spacing contract (fixes the giant page-load gap):** the outer `
` uses `style={formSectionStyle}` from `refinedStyles.js` (`clamp(88px, 9vh, 108px)` top — the section is the FIRST in-flow element and clears the absolute 64px NavBar itself). `FormPersistenceControls` renders INSIDE the form flow (right above the stepper card) with `sx={{ mt: 0, mb: 2 }}` — never move it back above the `
`: its default `mt: 10` (NavBar clearance for legacy pages) stacks with the section padding and produced ~250px of dead space before the masthead. -**Intro video (`IntroVideoField`, judge-only today — designed for mentor/hacker reuse):** `src/components/ApplicationForm/IntroVideoField.js` is a controlled field (URL string lives in the parent's `formData`, so persistence + submit flow through for free). Upload path reuses the portfolio bio-video signed-URL mint (`POST /api/users/profile/bio-video/upload-url` → XHR PUT to GCS) but deliberately NEVER calls the `POST /api/users/profile/bio-video` finalize — the applicant's public profile stays untouched; the file just lands under `users//` on the CDN. Link path allowlists YouTube/Vimeo/Loom (mirror of backend `ALLOWED_VIDEO_LINK_HOSTS` in `users_service.py` — keep in sync). Judge form: `formData.introductionVideoUrl`, required in `validateBackgroundAndExperience`, rendered in Step 2 after `whyJudge`, mapped in `loadFormDataSequentially`, GA `judge_app_intro_video_added` (`event_label` = upload|link), and surfaced as a link in admin review (`ApplicationReviewCard` judge `secondaryFields` + `labelMap` + all three `isLink` arrays). +**Intro video (`IntroVideoField`, judge-only today — designed for mentor/hacker reuse):** `src/components/ApplicationForm/IntroVideoField.js` is a controlled field (URL string lives in the parent's `formData`, so persistence + submit flow through for free). Upload path reuses the portfolio bio-video signed-URL mint (`POST /api/users/profile/bio-video/upload-url` → XHR PUT to GCS) but deliberately NEVER calls the `POST /api/users/profile/bio-video` finalize — the applicant's public profile stays untouched; the file just lands under `users//` on the CDN. Link path allowlists YouTube/Vimeo/Loom (mirror of backend `ALLOWED_VIDEO_LINK_HOSTS` in `users_service.py` — keep in sync). Judge form: `formData.introductionVideoUrl`, required in `validateBackgroundAndExperience`, rendered in Step 2 after `whyJudge`, mapped in `loadFormDataSequentially`, GA `judge_app_intro_video_added` (`event_label` = upload|link), and surfaced as a link in admin review (`ApplicationReviewCard` judge `secondaryFields` + `labelMap` + all three `isLink` arrays). **Judge photo (`UploadPhoto`, `formData.photoUrl`):** required + validated in `validateBackgroundAndExperience` (checks `formData.photoUrl || uploadedPhotoUrlRef.current`), rendered right after the video in a framed `--surface-2` panel with copy stating it's PUBLIC on DevPost + ohack.dev (deliberate contrast to the video's "review team only" note) — don't demote it back to a bare label/button below the video card (it was getting missed). Shared scaffolding lives in `src/components/ApplicationForm/`. Use these instead of re-implementing in each form: @@ -323,9 +342,29 @@ 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. +**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) + +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 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 @@ -413,8 +452,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. **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 @@ -773,6 +813,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/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..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 { MealMenu } from "../index"; +import { + DietaryRestrictionsSelect, + MealMenu, + MealSchedule, + MEALS_MODE_SCHEDULE, + getMealsMode, +} from "../index"; import { AGE_RANGE_OPTIONS, ARIZONA_COUNTY_OPTIONS, @@ -232,20 +238,23 @@ const LocationDemographicsStep = ({ {/* Only show dietary restrictions for non-online events */} {!eventData?.isOnlineEvent && ( - + setFormData((prev) => ({ ...prev, dietaryRestrictions: next })) + } /> )} {!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/JudgeTrainingGate.js b/src/components/ApplicationForm/JudgeTrainingGate.js new file mode 100644 index 00000000..06d4e9e0 --- /dev/null +++ b/src/components/ApplicationForm/JudgeTrainingGate.js @@ -0,0 +1,727 @@ +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 RefreshRounded from "@mui/icons-material/RefreshRounded"; +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"; + +// 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; + +const ensureExternalUserOnLms = (accessToken) => + lmsMutation("externalAuth:ensureExternalUser", {}, accessToken); + +const fetchMyLmsCertificates = (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, +// 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": + 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 ""; + } +}; + +const SLOT_PROBLEM_STATUSES = [ + "invalid", + "notfound", + "duplicate", + "mismatch", + "error", +]; + +/** + * Hard gate for the judge application: links applicants to the LMS judge + * 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). + * + * 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 + const [slots, setSlots] = useState(() => + Object.fromEntries( + JUDGE_TRAINING_CERTS.map((spec) => [ + spec.field, + { status: "empty", cert: null }, + ]), + ), + ); + 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] || ""; + + 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, auto-detect fills, localStorage restore, and previous-submission + // hydration). Auto-detected tokens verify straight from the seeded cache. + useEffect(() => { + const handle = setTimeout(() => { + evaluate(); + }, 600); + 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( + (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 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 }; + 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 && ( + + {/* 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(", ")} + + )} + + ); + }; + + 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. Pass both knowledge checks + and we'll detect your certificates automatically. + + + + + + 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. + + + Come back to this tab — we detect your certificates + automatically. (You can also paste the certificate links + manually.) + + + + + + {openLmsButton} + {(autoOutcome !== null || autoChecking) && checkAgainButton} + + + {/* Auto-detect status */} + {autoChecking && autoOutcome === null && ( + + + + Checking your training record on the LMS… + + + )} + {autoOutcome === "none" && !allVerified && ( + + + 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 ? ( + } + 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/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..7f1013b1 --- /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 + + {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__/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/__tests__/MealSchedule.test.js b/src/components/ApplicationForm/__tests__/MealSchedule.test.js new file mode 100644 index 00000000..02aeecea --- /dev/null +++ b/src/components/ApplicationForm/__tests__/MealSchedule.test.js @@ -0,0 +1,115 @@ +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("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(); + 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 e5d86c30..e868cbab 100644 --- a/src/components/ApplicationForm/index.js +++ b/src/components/ApplicationForm/index.js @@ -5,5 +5,25 @@ 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, + parseDietaryRestrictions, + serializeDietaryRestrictions, +} from "./DietaryRestrictionsSelect"; 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/Jobs/JobApplicationForm.js b/src/components/Jobs/JobApplicationForm.js new file mode 100644 index 00000000..ed1e5b7d --- /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", + "Ongoing — I'd love to stick around", +]; + +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/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/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/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 0a70bdda..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); @@ -89,6 +348,7 @@ const ApplicationReviewCard = ({ "portfolio", "motivation", "socialCauses", + "dietaryRestrictions", ], statusField: "isSelected", }, @@ -107,12 +367,15 @@ const ApplicationReviewCard = ({ "linkedin", "availability", "previousMentoring", + "dietaryRestrictions", ], statusField: "isSelected", }, 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", @@ -120,7 +383,6 @@ const ApplicationReviewCard = ({ "country", "state", "linkedinProfile", - "introductionVideoUrl", "backgroundAreas", ], additionalFields: [ @@ -130,6 +392,7 @@ const ApplicationReviewCard = ({ "additionalInfo", "pronouns", "otherBackground", + "dietaryRestrictions", "photoUrl", ], statusField: "isSelected", @@ -153,6 +416,7 @@ const ApplicationReviewCard = ({ "portfolio", "otherSocialCause", "shirtSize", + "dietaryRestrictions", "additionalInfo", ], statusField: "isSelected", @@ -402,7 +666,11 @@ 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", + dietaryRestrictions: "Dietary Restrictions", photoUrl: "Photo", status: "Status", // Sponsor-specific fields @@ -591,6 +859,16 @@ const ApplicationReviewCard = ({ })} + {/* Judge-only: intro video + LMS training verification */} + {applicationType === "judge" && ( + + )} + {/* Secondary information (visible when collapsed) */} {!expanded && ( @@ -599,14 +877,7 @@ const ApplicationReviewCard = ({ const value = application[field]; if (!value) return null; - const isLink = [ - "linkedin", - "github", - "portfolio", - "website", - "linkedinProfile", - "introductionVideoUrl", - ].includes(field); + const isLink = LINK_FIELDS.includes(field); return ( @@ -706,7 +977,7 @@ const ApplicationReviewCard = ({ }), }} > - {renderField(field, value)} + {renderField(field, value, LINK_FIELDS.includes(field))} ); @@ -723,14 +994,7 @@ const ApplicationReviewCard = ({ const value = application[field]; if (!value) return null; - const isLink = [ - "linkedin", - "github", - "portfolio", - "website", - "linkedinProfile", - "introductionVideoUrl", - ].includes(field); + const isLink = LINK_FIELDS.includes(field); return ( @@ -1300,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", @@ -1341,15 +1610,7 @@ const ApplicationReviewCard = ({ {extraEntries.map(([key, val]) => { - const isLink = [ - "linkedin", - "linkedinProfile", - "linkedinUrl", - "github", - "portfolio", - "website", - "introductionVideoUrl", - ].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/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/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/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 */} -
diff --git a/src/pages/admin/communication/index.js b/src/pages/admin/communication/index.js index f55e0346..16c14933 100644 --- a/src/pages/admin/communication/index.js +++ b/src/pages/admin/communication/index.js @@ -1,14 +1,19 @@ import React, { useState, useCallback, useEffect } from "react"; import { useRouter } from "next/router"; import { Typography, Tabs, Tab, Box } from "@mui/material"; -import { Email as EmailIcon, Share as ShareIcon } from "@mui/icons-material"; +import { + Email as EmailIcon, + Send as SendIcon, + Share as ShareIcon, +} from "@mui/icons-material"; import { useAuthInfo, withRequiredAuthInfo } from "@propelauth/react"; import AdminPage from "../../../components/admin/AdminPage"; import EmailTemplateManager from "../../../components/admin/EmailTemplateManager"; +import EmailCommunication from "../../../components/admin/EmailCommunication"; import SocialMediaManagement from "../../../components/admin/SocialMediaManagement"; -const TAB_SLUGS = ["templates", "social"]; +const TAB_SLUGS = ["templates", "email", "social"]; const CommunicationAdminPage = withRequiredAuthInfo(({ userClass }) => { const { accessToken } = useAuthInfo(); @@ -76,6 +81,7 @@ const CommunicationAdminPage = withRequiredAuthInfo(({ userClass }) => { iconPosition="start" label="Email Templates" /> + } iconPosition="start" label="Email" /> } iconPosition="start" label="Social Media" /> @@ -87,6 +93,13 @@ const CommunicationAdminPage = withRequiredAuthInfo(({ userClass }) => { /> )} {activeTab === 1 && ( + + )} + {activeTab === 2 && ( 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/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`} + { const [isSubmitting, setIsSubmitting] = useState(false); const [submitSuccess, setSubmitSuccess] = useState(false); const [submitError, setSubmitError] = useState(""); + const [confirmDialog, setConfirmDialog] = useState({ open: false, pendingType: null }); + // Derived: the full INQUIRY_TYPES entry for the pending confirmation + const pendingConfirmType = INQUIRY_TYPES.find((t) => t.value === confirmDialog.pendingType) ?? null; // Get inquiry type from URL query if available useEffect(() => { @@ -182,6 +178,15 @@ const ContactPage = () => { const { name, value, checked } = e.target; const newValue = e.target.type === "checkbox" ? checked : value; + // Intercept mentor/judge selection — show confirmation dialog first + if (name === "inquiryType") { + const selectedType = INQUIRY_TYPES.find((t) => t.value === value); + if (selectedType?.confirmUrl) { + setConfirmDialog({ open: true, pendingType: value }); + return; + } + } + setFormState((prev) => ({ ...prev, [name]: newValue, @@ -196,6 +201,16 @@ const ContactPage = () => { } }; + const handleConfirmRead = () => { + setFormState((prev) => ({ ...prev, inquiryType: String(confirmDialog.pendingType) })); + setFormErrors((prev) => ({ ...prev, inquiryType: "" })); + setConfirmDialog({ open: false, pendingType: null }); + }; + + const handleCancelConfirm = () => { + setConfirmDialog({ open: false, pendingType: null }); + }; + // Validate form const validateForm = () => { const errors = {}; @@ -661,6 +676,46 @@ const ContactPage = () => { + + {/* Mentor / Judge read-confirmation dialog */} + + + Before you apply as a {pendingConfirmType?.label} + + + + Please take a moment to read our{" "} + {pendingConfirmType?.confirmUrlText} so you + know what to expect. It covers the responsibilities, time + commitment, and selection criteria. + + {pendingConfirmType?.confirmUrl && ( + + )} + + + + + + ); }; 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 69ce3689..8292422c 100644 --- a/src/pages/hack/[event_id]/judge-application.js +++ b/src/pages/hack/[event_id]/judge-application.js @@ -63,7 +63,10 @@ import { Stat, } from "../../../components/design/refined"; import { + DietaryRestrictionsSelect, IntroVideoField, + JudgeTrainingGate, + MealSchedule, OHackParticipationSelect, PronounsPicker, scrollToStepContent, @@ -158,6 +161,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, @@ -180,7 +187,6 @@ const JudgeApplicationComponent = () => { () => ({ timestamp: new Date().toISOString(), email: "", - selected: false, name: "", title: "", biography: "", @@ -188,6 +194,7 @@ const JudgeApplicationComponent = () => { availability: "", canAttendJudging: "", // New field for judging availability confirmation inPerson: "", + dietaryRestrictions: "", additionalInfo: "", companyName: "", codeOfConduct: false, @@ -199,6 +206,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: "", @@ -305,6 +316,7 @@ const JudgeApplicationComponent = () => { availability: prevData.availability || "", canAttendJudging: prevData.canAttendJudging || "", inPerson: prevData.inPerson || "", + dietaryRestrictions: prevData.dietaryRestrictions || "", // Additional info additionalInfo: prevData.additionalInfo || "", @@ -319,6 +331,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, }; @@ -1242,6 +1261,13 @@ const JudgeApplicationComponent = () => { return false; } + if (!formData.photoUrl && !uploadedPhotoUrlRef.current) { + setError( + "Please upload a photo of yourself — it appears next to your name on the event's DevPost site and on ohack.dev", + ); + return false; + } + setError(""); return true; }; @@ -1294,6 +1320,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 +1388,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), @@ -1720,19 +1758,41 @@ const JudgeApplicationComponent = () => { } /> - + + + ); @@ -1760,11 +1820,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. @@ -1971,6 +2030,25 @@ const JudgeApplicationComponent = () => { )} + + {/* 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" && ( + + setFormData((prev) => ({ ...prev, dietaryRestrictions: next })) + } + MenuProps={refinedSelectMenuProps} + sx={refinedFieldSx} + /> + )} ); @@ -2012,9 +2090,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)" }} @@ -2031,7 +2113,11 @@ const JudgeApplicationComponent = () => { /> } label={ - + I agree to the{" "} { - 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. - - {/* Selected field is not shown to users but stored in state */} - ); @@ -2203,10 +2288,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. @@ -2594,235 +2681,256 @@ 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} + accessToken={accessToken} /> - - - {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)} + + + + + + + +
- -
+ + )} )} diff --git a/src/pages/hack/[event_id]/mentor-application.js b/src/pages/hack/[event_id]/mentor-application.js index 62eef401..911e00eb 100644 --- a/src/pages/hack/[event_id]/mentor-application.js +++ b/src/pages/hack/[event_id]/mentor-application.js @@ -53,6 +53,8 @@ import { Stat, } from "../../../components/design/refined"; import { + DietaryRestrictionsSelect, + MealSchedule, OHackParticipationSelect, PronounsPicker, scrollToStepContent, @@ -173,6 +175,7 @@ const MentorApplicationComponent = () => { picture: "", linkedin: "", inPerson: "", + dietaryRestrictions: "", expertise: [], // Changed from string to array otherExpertise: "", // New field for "Other" option participationCount: "", @@ -192,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 @@ -457,6 +461,7 @@ const MentorApplicationComponent = () => { "https://cdn.ohack.dev/ohack.dev/2023_hackathon_2.webp", isEventPast, timezone: eventData.timezone, + constraints: eventData.constraints || {}, }); // Generate time slots based on event dates @@ -550,6 +555,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 +1551,25 @@ const MentorApplicationComponent = () => { )} + {/* 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!" && ( + + setFormData((prev) => ({ ...prev, dietaryRestrictions: next })) + } + MenuProps={refinedSelectMenuProps} + sx={refinedFieldSx} + /> + )} + {/* Location fields - conditional labels and requirements */} @@ -2051,10 +2076,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. @@ -2113,10 +2140,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]/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 659f0b90..13c68d5e 100644 --- a/src/pages/hack/[event_id]/volunteer-application.js +++ b/src/pages/hack/[event_id]/volunteer-application.js @@ -49,6 +49,8 @@ import FormPersistenceControls from "../../../components/FormPersistenceControls import { useFormPersistence } from "../../../hooks/use-form-persistence"; import { useRecaptcha } from "../../../hooks/use-recaptcha"; import { + DietaryRestrictionsSelect, + MealSchedule, PronounsPicker, scrollToStepContent, } from "../../../components/ApplicationForm"; @@ -177,6 +179,7 @@ const VolunteerApplicationComponent = () => { country: "", state: "", inPerson: "", + dietaryRestrictions: "", experienceLevel: "", shirtSize: "", volunteerType: [], @@ -190,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 }; @@ -853,6 +857,7 @@ const VolunteerApplicationComponent = () => { "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 @@ -1044,6 +1049,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"), @@ -1832,9 +1838,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. @@ -1910,6 +1919,29 @@ const VolunteerApplicationComponent = () => { )} + {/* 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" && ( + + setFormData((prev) => ({ + ...prev, + dietaryRestrictions: next, + })) + } + MenuProps={refinedSelectMenuProps} + sx={refinedFieldSx} + /> + )} + {/* Show blocking alert for incompatible selection */} {hasIncompatibleSelection && ( @@ -2479,11 +2511,21 @@ const VolunteerApplicationComponent = () => { - + + + Thank you for applying to volunteer with Opportunity Hack + — we've received your application. + + + + - Thank you for applying to volunteer with Opportunity Hack. - We'll review your application and contact you with next - steps soon. + 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. 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 */}