From e07dc60cee1e2de2dd6eec0f3b3d33eee852a1b1 Mon Sep 17 00:00:00 2001 From: Greg V <6913307+gregv@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:51:29 -0700 Subject: [PATCH] Add volunteer job board: /jobs pages, application form, and admin Public /jobs index and /jobs/[slug] detail pages (ISR, refined design, JobPosting schema with employmentType VOLUNTEER for Google for Jobs). Login-gated 4-step application form with required work sample, PDF resume upload, and intro video. /admin/jobs manages listings and reviews applications with one-click accept/kind-rejection emails. Sitemaps, admin nav registration, and a /volunteer cross-link included. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 10 + next-sitemap.config.js | 4 +- src/components/Jobs/JobApplicationForm.js | 865 +++++++++++++++++++ src/components/Jobs/ResumeUploadField.js | 229 +++++ src/components/Jobs/ShareRow.js | 74 ++ src/components/admin/AdminNavigation.js | 6 + src/components/admin/jobs/ApplicationsTab.js | 585 +++++++++++++ src/components/admin/jobs/ListingsTab.js | 537 ++++++++++++ src/pages/admin/index.js | 7 + src/pages/admin/jobs/index.js | 118 +++ src/pages/jobs/[slug].js | 374 ++++++++ src/pages/jobs/index.js | 439 ++++++++++ src/pages/server-sitemap.xml.js | 11 + src/pages/volunteer/index.js | 14 + 14 files changed, 3272 insertions(+), 1 deletion(-) create mode 100644 src/components/Jobs/JobApplicationForm.js create mode 100644 src/components/Jobs/ResumeUploadField.js create mode 100644 src/components/Jobs/ShareRow.js create mode 100644 src/components/admin/jobs/ApplicationsTab.js create mode 100644 src/components/admin/jobs/ListingsTab.js create mode 100644 src/pages/admin/jobs/index.js create mode 100644 src/pages/jobs/[slug].js create mode 100644 src/pages/jobs/index.js diff --git a/CLAUDE.md b/CLAUDE.md index e2cb1d83..9f8c4ced 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -787,6 +787,16 @@ Post/live-event feedback for selected volunteers + nonprofit partners. Both rout - **Auth/CAPTCHA**: not gated by `RequiredAuthProvider` — nonprofits/anonymous can submit. Logged-in `isSelected` volunteers are trusted (no CAPTCHA); everyone else gets an invisible reCAPTCHA v3 token (`useRecaptcha`). Mode `upcoming` shows a "not started yet" card; `live`→"how's it going", `post`→"how was your experience". Pages are `noindex`. Backend computes mode (timezone-aware) — the frontend does NOT recompute dates. - **Discoverability**: `src/components/Survey/SurveyCTA.js` — a self-contained CTA (var-fallback inline styles so it works in or out of `RefinedRoot`; mount-gated to avoid SSR/ISR hydration mismatch) linking to `/hack//survey`, rendered only once the event has **started** (live or ended; hidden for upcoming). Wired into: the event page `/hack/[event_id]` (after the masthead — the only surface reaching anonymous nonprofits), `mentor-checkin`, `manageteam`, `team/[team_id]`, and `judge-application` (gated on `isSelected`). Pass `eventId` + `startDate`/`endDate`/`timezone`; the component owns the visibility gate. +## Volunteer Job Board (`/jobs`, `/jobs/[slug]`, `/admin/jobs` — Aug 2026) + +Volunteer organizer roles (Social Media Manager, Hackathon Operations Lead — Phoenix, Mentor Program Lead) with an AI-resistant application: required intro **video** (reuses `IntroVideoField` — its bio-video upload mint works as-is because applying requires login), required **PDF resume** (`src/components/Jobs/ResumeUploadField.js` → `POST /api/jobs/apply/resume-upload-url`, clone of the bio-video signed-URL flow, lands at `job_applications//` on the public CDN — accepted obscured-URL risk), a role-specific **work sample** (min 200 chars, mirrored in backend `MIN_JOB_WORK_SAMPLE_LENGTH`), and a **reply-within-5-days responsiveness test** baked into the confirmation email. Load-bearing details: + +- **Backend**: blueprint `api/jobs/` (`jobs_views.py` + `jobs_service.py`; registered in `api/__init__.py`). Collections: `job_listings` (doc id = slug, **slug immutable after create**) and `job_applications` (uuid4). Public `GET /api/jobs` returns published+closed (lean fields); `GET /api/jobs/` 404s drafts/hidden but **returns closed** so shared links render a calm closed panel. Apply/`me` routes are `@auth.require_user`; submit verifies recaptcha (volunteers_service `verify_recaptcha` + FLASK_ENV=development bypass), re-verifies resume/video URLs against the caller's own CDN prefix via `get_blob_metadata`, and 409s duplicates (listing_slug+user_id). Validators in `common/utils/validators.py` (`validate_job_listing[_partial]`, `validate_job_application`, `ALLOWED_JOB_*`). Emails (Resend, `_notifications_disabled()` gate): applicant confirmation (`reply_to` questions@ohack.org + the reply-to-confirm ask), FYI to questions@ohack.org, and warm accept/reject decision emails (`POST /api/jobs/admin/applications//decision`, optional `personal_note`). TTL caches (300s) cleared on every admin write. Seed: `scripts/seed_job_listings.py` (dry-run default, skips existing slugs, seeds drafts). +- **Frontend pages**: both ISR revalidate 300. `/jobs` index is a refined pillar page (FAQ_ITEMS module-scope → `
` + FAQPage JSON-LD); its getStaticProps treats a 404 from `/api/jobs` as empty (deploy-ordering: backend must ship first or the page renders the empty state) but **rethrows other errors** (ISR keeps last good). `/jobs/[slug]` SSRs the listing publicly (SEO/unfurls) with a **top-level `JobPosting` JSON-LD node** (`employmentType: "VOLUNTEER"` → Google for Jobs; TELECOMMUTE + applicantLocationRequirements for remote/hybrid, Tempe `jobLocation` for phoenix_in_person; `datePosted`/`validThrough` set conditionally — **Next rejects `undefined` in props**). Only the `#apply` section is auth-gated — via `useAuthInfo` + `redirectToLoginPage` (app-level AuthProvider), NOT a page-level RequiredAuthProvider which would hide content from crawlers. +- **`JobApplicationForm`** (`src/components/Jobs/`): 4 steps, mentor-form patterns (refinedStyles imports, `useFormPersistence` localStorage autosave with `formType:"job"`/`eventId:slug` — `loadPreviousSubmission` deliberately NOT called; already-applied comes from `GET /api/jobs//applications/me` with a 6s `AbortSignal.timeout` so a slow backend can't pin the spinner). **The `
` MUST keep `noValidate`** — the required MUI Selects render hidden native inputs and browser constraint validation otherwise silently blocks submission (no submit event, no visible error; this bit us). Phoenix listing (`location_type === "phoenix_in_person"`) hard-blocks `inPersonOk !== "Yes"`; hours below `listing.min_hours_per_week` blocks with a kind redirect message. Never add `isSelected`/staff-owned fields to `initialFormData`. +- **Admin** `/admin/jobs?tab=listings|applications` (blog-admin auth pattern; registered in BOTH nav registries): `src/components/admin/jobs/ListingsTab.js` (table + edit Dialog — deliberately no blog-style editor pages; publish/hide quick toggle) and `ApplicationsTab.js` (filters, detail Dialog derived live from list state — stale-snapshot gotcha —, status/notes PATCH, one-click kind-rejection/accept decision emails). +- **SEO plumbing**: `/jobs/[slug]` in next-sitemap `exclude` + `jobs` substring in the 0.8-priority branch; jobs block in `server-sitemap.xml.js`. Cross-link card on `/volunteer` (`#roles` section). Footer deliberately untouched (CWV height contract). + ## Admin Feedback review (`/admin/feedback`) One `volunteer.admin`-gated page (`src/pages/admin/feedback/index.js`) with 3 MUI tabs over 3 distinct data sources (different scopes — don't merge them into one table). Plain MUI, standard `AdminPage` + `RequiredAuthProvider` shell. Registered in BOTH nav registries (`src/components/admin/AdminNavigation.js` + `src/pages/admin/index.js`). Only the active tab mounts (lazy fetch). Panels live in `src/components/admin/feedback/`: diff --git a/next-sitemap.config.js b/next-sitemap.config.js index 88dd6beb..3e923219 100644 --- a/next-sitemap.config.js +++ b/next-sitemap.config.js @@ -14,6 +14,7 @@ module.exports = { "/hackathon/[hackathon_id]", "/project/[project_id]", "/hack/[event_id]", + "/jobs/[slug]", // Dynamic routes covered by /server-sitemap.xml instead "https://api.test.ohack.dev/", "https://test.api.ohack.dev/", @@ -60,7 +61,8 @@ module.exports = { path.includes("recruit") || path.includes("hackathon") || path.includes("social-good") || - path.includes("nonprofits") + path.includes("nonprofits") || + path.includes("jobs") ) { priority = 0.8; changefreq = "weekly"; diff --git a/src/components/Jobs/JobApplicationForm.js b/src/components/Jobs/JobApplicationForm.js new file mode 100644 index 00000000..0b86d1e5 --- /dev/null +++ b/src/components/Jobs/JobApplicationForm.js @@ -0,0 +1,865 @@ +import React, { useEffect, useRef, useState } from "react"; +import { + Alert, + Box, + Button, + Checkbox, + CircularProgress, + FormControl, + FormControlLabel, + FormHelperText, + InputLabel, + MenuItem, + Select, + Step, + StepLabel, + Stepper, + TextField, + Typography, + useMediaQuery, + useTheme, +} from "@mui/material"; +import { ThemeProvider } from "@mui/material/styles"; +import { useAuthInfo } from "@propelauth/react"; +import ReactMarkdown from "react-markdown"; + +import { useEnv } from "../../context/env.context"; +import { trackEvent } from "../../lib/ga"; +import { useFormPersistence } from "../../hooks/use-form-persistence"; +import { useRecaptcha } from "../../hooks/use-recaptcha"; +import FormPersistenceControls from "../FormPersistenceControls"; +import { IntroVideoField, PronounsPicker, scrollToStepContent } from "../ApplicationForm"; +import { + refinedFormTheme, + refinedFieldSx, + refinedChoiceSx, + refinedSelectMenuProps, + stepTitleSx, + stepLeadSx, + eventMarkdownSx, + infoAlertSx, + warningAlertSx, + successAlertSx, + errorAlertSx, + emphasisPanelSx, + primaryButtonSx, + ghostButtonSx, + refinedStepperSx, + refinedStepperMobileSx, + formProseSx, +} from "../ApplicationForm/refinedStyles"; +import ResumeUploadField from "./ResumeUploadField"; +import ShareRow from "./ShareRow"; +import { Eyebrow } from "../design/refined"; + +// Volunteer job application form, rendered in the #apply section of +// /jobs/[slug] for logged-in users. Modeled on the mentor application +// (the canonical refined form): useFormPersistence for localStorage autosave, +// shared refinedStyles, step scroll via scrollToStepContent. The video is +// REQUIRED and one prompt references the work-sample answer — that pairing is +// the AI/low-effort filter, don't soften it. +// NOTE: loadPreviousSubmission is deliberately NOT used — the jobs API has its +// own GET /api/jobs//applications/me for the already-applied panel. + +const STEPS = ["About you", "Commitment", "Work sample", "Video & review"]; + +const HOURS_OPTIONS = [ + { label: "1–2 hours", floor: 1 }, + { label: "3–5 hours", floor: 3 }, + { label: "6–8 hours", floor: 6 }, + { label: "9+ hours", floor: 9 }, +]; + +const DURATION_OPTIONS = [ + "Through the Fall 2026 event", + "3–6 months", + "6–12 months", + "As long as I'm useful", +]; + +const CHANNEL_OPTIONS = ["Slack", "Email", "Either works"]; + +const SLACK_OPTIONS = ["Yes, I'm in the OHack Slack", "Not yet"]; + +const MIN_WORK_SAMPLE_CHARS = 200; // keep in sync with backend MIN_JOB_WORK_SAMPLE_LENGTH + +const EMAIL_RE = /^\S+@\S+\.\S+$/; + +const hoursFloor = (label) => + HOURS_OPTIONS.find((o) => o.label === label)?.floor ?? 0; + +const isLinkedInUrl = (value) => { + try { + const parsed = new URL(value); + return ( + (parsed.protocol === "https:" || parsed.protocol === "http:") && + parsed.hostname.toLowerCase().includes("linkedin.com") + ); + } catch (e) { + return false; + } +}; + +const initialFormData = { + name: "", + email: "", + pronouns: "", + phone: "", + location: "", + linkedinUrl: "", + inPersonOk: "", + visaAck: false, + hoursPerWeek: "", + durationCommitment: "", + preferredChannel: "", + slackMember: "", + referralSource: "", + workSampleAnswer: "", + whyOhack: "", + resumeUrl: "", + videoUrl: "", +}; + +export default function JobApplicationForm({ listing }) { + const { user, accessToken } = useAuthInfo(); + const { apiServerUrl } = useEnv(); + const { getRecaptchaToken } = useRecaptcha(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("sm")); + + const [activeStep, setActiveStep] = useState(0); + const [error, setError] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [success, setSuccess] = useState(false); + const [alreadyApplied, setAlreadyApplied] = useState(null); // null | {status} + const [checkingApplied, setCheckingApplied] = useState(true); + + const stepContentRef = useRef(null); + const appliedCheckRanRef = useRef(false); + const accessTokenRef = useRef(accessToken); + accessTokenRef.current = accessToken; + + const { + formData, + setFormData, + formRef, + handleFormChange, + loadFromLocalStorage, + saveToLocalStorage, + clearSavedData, + notification, + closeNotification, + } = useFormPersistence({ + formType: "job", + eventId: listing.slug, + userId: user?.userId, + initialFormData, + apiServerUrl, + accessToken, + }); + + const isPhoenixRole = listing.location_type === "phoenix_in_person"; + const minHours = listing.min_hours_per_week || 0; + + // Restore an in-progress draft, then prefill identity fields that are empty. + useEffect(() => { + loadFromLocalStorage(); + }, []); + + useEffect(() => { + if (!user) return; + setFormData((prev) => ({ + ...prev, + name: prev.name || [user.firstName, user.lastName].filter(Boolean).join(" "), + email: prev.email || user.email || "", + })); + }, [user?.userId]); + + // Already-applied check — gated on token PRESENCE (PropelAuth rotates the + // token on refocus; the raw value must never key an effect), run once. + useEffect(() => { + if (!accessToken || appliedCheckRanRef.current) return; + appliedCheckRanRef.current = true; + const check = async () => { + try { + // Timeout so a slow backend can't pin the spinner forever — worst + // case the form renders and the backend 409s a duplicate on submit. + const res = await fetch( + `${apiServerUrl}/api/jobs/${listing.slug}/applications/me`, + { + headers: { Authorization: `Bearer ${accessTokenRef.current}` }, + signal: AbortSignal.timeout(6000), + }, + ); + if (res.ok) { + const data = await res.json(); + if (data.applied) setAlreadyApplied(data); + } + } catch (e) { + // Non-fatal — worst case the backend 409s on submit + } finally { + setCheckingApplied(false); + } + }; + check(); + }, [accessToken, apiServerUrl, listing.slug]); + + useEffect(() => { + if (!accessToken && checkingApplied) { + // No token yet (auth still resolving) — don't block the form forever + const t = setTimeout(() => setCheckingApplied(false), 4000); + return () => clearTimeout(t); + } + return undefined; + }, [accessToken, checkingApplied]); + + const setField = (name, value) => { + setFormData((prev) => { + const next = { ...prev, [name]: value }; + return next; + }); + }; + + const trackStep = (action, label) => { + trackEvent({ + action, + params: { event_label: label, page: "job_application", job: listing.slug }, + }); + }; + + // ----- validation (single top-level error string, mentor-form style) ----- + + const validateAboutYou = () => { + if (!formData.name.trim()) { + setError("Please tell us your name."); + return false; + } + if (!EMAIL_RE.test(formData.email.trim())) { + setError("Please enter a valid email address."); + return false; + } + if (!isLinkedInUrl(formData.linkedinUrl.trim())) { + setError("Please paste your LinkedIn profile URL (it should look like linkedin.com/in/your-name)."); + return false; + } + if (isPhoenixRole && formData.inPersonOk !== "Yes") { + setError( + "This role requires being on-site in Phoenix/Tempe for the event weekend. If that's not possible for you, take a look at our remote roles — we'd still love your help.", + ); + return false; + } + if (!formData.visaAck) { + setError("Please confirm you understand this is an unpaid volunteer role and we cannot sponsor visas."); + return false; + } + setError(""); + return true; + }; + + const validateCommitment = () => { + if (!formData.hoursPerWeek) { + setError("Please tell us how many hours a week you can give."); + return false; + } + if (hoursFloor(formData.hoursPerWeek) < minHours) { + setError( + `This role really needs at least ${minHours} hours a week to succeed. If that's more than you can commit right now, volunteering at the hackathon itself is a great way to plug in — no hard feelings at all.`, + ); + return false; + } + if (!formData.durationCommitment) { + setError("Please tell us how long you can stick with us."); + return false; + } + if (!formData.preferredChannel) { + setError("Please pick a preferred communication channel."); + return false; + } + setError(""); + return true; + }; + + const validateWorkSample = () => { + const chars = formData.workSampleAnswer.trim().length; + if (chars < MIN_WORK_SAMPLE_CHARS) { + setError( + `Your work sample needs a bit more depth — at least ${MIN_WORK_SAMPLE_CHARS} characters (you have ${chars}). This is the part we read most closely.`, + ); + return false; + } + if (!formData.resumeUrl) { + setError("Please upload your resume (PDF)."); + return false; + } + setError(""); + return true; + }; + + const validateVideo = () => { + if (!formData.videoUrl) { + setError("The video is required — it's how we know we're talking to you. Upload a file or paste a YouTube/Vimeo/Loom link."); + return false; + } + setError(""); + return true; + }; + + const STEP_VALIDATORS = [validateAboutYou, validateCommitment, validateWorkSample, validateVideo]; + + const handleNext = () => { + if (!STEP_VALIDATORS[activeStep]()) return; + if (activeStep === STEPS.length - 1) { + handleSubmit(); + return; + } + const next = activeStep + 1; + setActiveStep(next); + trackStep("job_app_step", STEPS[next]); + scrollToStepContent(stepContentRef); + }; + + const handleBack = () => { + if (activeStep === 0) return; + setActiveStep(activeStep - 1); + scrollToStepContent(stepContentRef); + }; + + const handleSubmit = async () => { + for (const validate of STEP_VALIDATORS) { + if (!validate()) return; + } + setSubmitting(true); + setError(""); + try { + const recaptchaToken = await getRecaptchaToken("job_application"); + if (!recaptchaToken && process.env.NODE_ENV === "production") { + setError("Could not verify you're human — please refresh and try again."); + return; + } + + const payload = { + name: formData.name.trim(), + email: formData.email.trim(), + pronouns: formData.pronouns, + phone: formData.phone.trim(), + location: formData.location.trim(), + linkedin_url: formData.linkedinUrl.trim(), + resume_url: formData.resumeUrl, + video_url: formData.videoUrl, + hours_per_week: formData.hoursPerWeek, + duration_commitment: formData.durationCommitment, + preferred_channel: formData.preferredChannel, + slack_member: formData.slackMember, + in_person_ok: formData.inPersonOk === "Yes", + visa_ack: formData.visaAck, + work_sample_answer: formData.workSampleAnswer.trim(), + why_ohack: formData.whyOhack.trim(), + referral_source: formData.referralSource.trim(), + recaptchaToken, + }; + + const res = await fetch(`${apiServerUrl}/api/jobs/${listing.slug}/apply`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessTokenRef.current}`, + }, + body: JSON.stringify(payload), + }); + const data = await res.json().catch(() => ({})); + + if (res.status === 409) { + setAlreadyApplied({ applied: true, status: "submitted" }); + return; + } + if (!res.ok) { + setError(data.error || "Something went wrong submitting your application — please try again."); + trackStep("job_app_submit_error", data.error || String(res.status)); + return; + } + + clearSavedData(); + setSuccess(true); + trackStep("job_app_submit", listing.slug); + scrollToStepContent(stepContentRef); + } catch (e) { + setError("Network error — please check your connection and try again."); + trackStep("job_app_submit_error", "network"); + } finally { + setSubmitting(false); + } + }; + + // ----- step content ----- + + const renderAboutYou = () => ( + + Step 1 of 4 + + About you + + + The basics, plus where to find your professional footprint. + + + + + setField("pronouns", value)} + /> + + + + + {isPhoenixRole && ( + + + Can you be on-site in Phoenix/Tempe, including the full event weekend? + + + + This role runs the physical event, so it can't be done remotely. + + + )} + + + + This is an unpaid volunteer role with a 501(c)(3) + nonprofit. We are unable to sponsor visas. What we can offer: + real portfolio work, Hearts toward certificates, and LinkedIn + recommendations & references from work that actually shipped. + + + setField("visaAck", e.target.checked)} + sx={refinedChoiceSx} + /> + } + label="I understand this is an unpaid volunteer role and that Opportunity Hack cannot sponsor visas." + /> + + ); + + const renderCommitment = () => ( + + Step 2 of 4 + + Commitment & communication + + + Honest numbers beat optimistic ones — we plan around what you tell us + here. + + + + + Hours per week you can reliably give + + + + This role needs about {listing.hours_per_week_label} hours a week. + + + + + How long can you stick around? + + {listing.duration_ask} + + + + Preferred way to coordinate + + + We run on Slack day-to-day, with email for anything formal. + + + + + Are you in our Slack yet? + + + + + + ); + + const renderWorkSample = () => { + const chars = formData.workSampleAnswer.trim().length; + return ( + + Step 3 of 4 + + The work sample + + + This is the fun part — and the part we read most closely. There's + no single right answer; we want to see how you think. + + + + + {listing.work_sample_prompt || ""} + + + + + + + + { + setField("resumeUrl", url); + if (url) trackStep("job_app_resume_uploaded", listing.slug); + }} + accessToken={accessToken} + apiServerUrl={apiServerUrl} + /> + + ); + }; + + const renderVideoAndReview = () => ( + + Step 4 of 4 + + Your video & review + + + A short video (under 2 minutes) answering the prompts below. Phone + camera is perfect — we care about the person, not the production. + + + + + Answer these on camera: + + + {(listing.video_prompts || []).map((prompt) => ( + + {prompt} + + ))} + + + + setField("videoUrl", url)} + accessToken={accessToken} + apiServerUrl={apiServerUrl} + onVideoAdded={(method) => trackStep("job_app_video_added", method)} + /> + + + + Quick review + + + {formData.name} · {formData.email} +
+ {formData.hoursPerWeek} per week · {formData.durationCommitment} +
+ Resume {formData.resumeUrl ? "✓" : "✗"} · Video {formData.videoUrl ? "✓" : "✗"} +
+ + After you submit, we'll email a confirmation — reply to + it within 5 days to confirm your application is active. + Consider it the first task of the role. + +
+
+ ); + + const stepRenderers = [renderAboutYou, renderCommitment, renderWorkSample, renderVideoAndReview]; + + // ----- top-level render states ----- + + if (checkingApplied) { + return ( + + + + ); + } + + if (alreadyApplied) { + return ( + + + Application on file + + You've already applied — nice. + + + + We have your application for this role + {alreadyApplied.status ? ` (status: ${alreadyApplied.status})` : ""}. + Check your inbox for the confirmation email — if you haven't + replied to it yet, doing so confirms your application is active. + We'll reach out from questions@ohack.org for next steps. + + + + + ); + } + + if (success) { + return ( + + + Application received + + Submitted — one thing left. + + + + Your application is in. We just sent a confirmation email —{" "} + reply to it within 5 days to confirm your + application is active. We review by hand and typically reach out + within a week to set up a call. + + + + While you wait: join our{" "} + Slack community and say + hi in #introductions — it's where the actual work happens. + + + + + ); + } + + return ( + + + + + + + {STEPS.map((label) => ( + + {label} + + ))} + + + + + {/* noValidate: the required MUI Selects render hidden native inputs; + without it the browser's constraint validation silently blocks + submit (invalid control not focusable → no submit event at all). + Our per-step JS validators own all validation. */} + { + e.preventDefault(); + handleNext(); + }} + > + + {stepRenderers[activeStep]()} + + + {error && ( + + {error} + + )} + + + + + + + + + + ); +} diff --git a/src/components/Jobs/ResumeUploadField.js b/src/components/Jobs/ResumeUploadField.js new file mode 100644 index 00000000..6394cad6 --- /dev/null +++ b/src/components/Jobs/ResumeUploadField.js @@ -0,0 +1,229 @@ +import React, { useRef, useState } from "react"; +import { + Box, + Button, + LinearProgress, + Link, + Typography, +} from "@mui/material"; +import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; +import { + ghostButtonSx, + refinedInlineLinkSx, +} from "../ApplicationForm/refinedStyles"; + +// Resume (PDF) upload for the volunteer job application form. A controlled +// field like IntroVideoField: the uploaded file's CDN URL lives in the +// parent's formData; this component only acquires it via the jobs signed-URL +// mint (POST /api/jobs/apply/resume-upload-url → XHR PUT to GCS). Requires a +// logged-in user (the endpoint resolves the caller's user doc to build the +// job_applications// path the backend later verifies on submit). +// Styling assumes an ancestor . + +const MAX_BYTES = 10 * 1024 * 1024; // keep in sync with backend MAX_RESUME_BYTES +const CONTENT_TYPE = "application/pdf"; + +export default function ResumeUploadField({ + value, + onChange, + accessToken, + apiServerUrl = process.env.NEXT_PUBLIC_API_SERVER_URL, + label = "Your resume (PDF)", + helperText = "One PDF, up to 10MB. We use it to understand your background before our call — polish matters less than honesty.", + required = false, + error = "", + onUploaded, // optional () => void, for analytics +}) { + const [progress, setProgress] = useState(null); // null | 0..100 + const [busy, setBusy] = useState(false); + const [localError, setLocalError] = useState(""); + const fileInputRef = useRef(null); + + const handleFile = async (event) => { + const file = event.target.files?.[0]; + event.target.value = ""; // allow re-selecting the same file + if (!file) return; + setLocalError(""); + + const isPdf = + file.type === CONTENT_TYPE || /\.pdf$/i.test(file.name || ""); + if (!isPdf) { + setLocalError("Please choose a PDF file"); + return; + } + if (file.size > MAX_BYTES) { + setLocalError("Resume must be under 10MB"); + return; + } + + setBusy(true); + setProgress(0); + try { + const urlRes = await fetch( + `${apiServerUrl}/api/jobs/apply/resume-upload-url`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + content_type: CONTENT_TYPE, + content_length: file.size, + }), + }, + ); + const urlData = await urlRes.json().catch(() => ({})); + if (!urlRes.ok) { + setLocalError(urlData.error || "Could not start the upload"); + return; + } + + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("PUT", urlData.upload_url); + // GCS verifies these against the signed headers + for (const [header, headerValue] of Object.entries( + urlData.required_headers || {}, + )) { + xhr.setRequestHeader(header, headerValue); + } + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) + setProgress(Math.round((e.loaded / e.total) * 100)); + }; + xhr.onload = () => + xhr.status >= 200 && xhr.status < 300 + ? resolve() + : reject(new Error(`Upload failed (${xhr.status})`)); + xhr.onerror = () => + reject(new Error("Upload failed — check your connection")); + xhr.send(file); + }); + + onChange(urlData.final_url); + if (onUploaded) onUploaded(); + } catch (err) { + setLocalError(err.message || "Upload failed"); + } finally { + setProgress(null); + setBusy(false); + } + }; + + const shownError = localError || error; + + return ( + + + {label} + {required ? " *" : ""} + + + {helperText} + + + + {value ? ( + + + Your resume:{" "} + + View uploaded PDF + + + + + + + + ) : ( + + )} + + {progress !== null && ( + + + + Uploading… {progress}% + + + )} + + + {shownError && ( + + {shownError} + + )} + + + + ); +} diff --git a/src/components/Jobs/ShareRow.js b/src/components/Jobs/ShareRow.js new file mode 100644 index 00000000..69aee4f2 --- /dev/null +++ b/src/components/Jobs/ShareRow.js @@ -0,0 +1,74 @@ +import React, { useState } from "react"; +import { trackEvent } from "../../lib/ga"; + +// Share buttons for a job listing (copy link / LinkedIn / X). Plain refined +// .ohx-btn--ghost anchors so it works anywhere inside a . The +// share text is pre-written so a supporter can post in one click. + +export default function ShareRow({ url, title, slug, heading }) { + const [copied, setCopied] = useState(false); + + const shareText = `${title} — a volunteer role at Opportunity Hack. Real portfolio work for social good:`; + const encodedUrl = encodeURIComponent(url); + const encodedText = encodeURIComponent(shareText); + + const track = (method) => { + trackEvent({ + action: "job_share", + params: { event_label: slug, method }, + }); + }; + + const handleCopy = async () => { + track("copy_link"); + try { + await navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (e) { + // Clipboard API unavailable (http / permissions) — best effort only + window.prompt("Copy this link:", url); + } + }; + + return ( +
+ {heading && ( +

+ {heading} +

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

+ Log in to apply +

+

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

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

+ + ← All volunteer roles + +

+ Volunteer role · Opportunity Hack +

+ {listing.title} +

+

+ {listing.summary} +

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

+ This role is no longer accepting applications. +

+

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

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

+ Commitment: {listing.duration_ask} +

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

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

+

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

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

+ Your first task starts here. +

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

${listing.summary}

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

+ Help run Opportunity Hack. +

+

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

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

+ Where we need you. +

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

No open roles right now.

+

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

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

+ {role.title} +

+

+ {role.summary} +

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

+ Recently closed +

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

+ Unpaid ≠ unrewarded. +

+

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

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

{item.title}

+

{item.body}

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

+ The application is part of the interview. +

+

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

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

{title}

+

{body}

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

+ The honest FAQ. +

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

+ {item.a} +

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

+ Do work that matters — and counts. +

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

Want a bigger role? Help run Opportunity Hack.

+

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

+
+ track("roles_cta", "jobs_page")}> + See organizer roles + +
{/* ADDITIONAL RESOURCES */}