Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
7e27ea6
Judge application: hard-gate behind LMS training certificates
gregv Aug 18, 2026
11b8d87
Judge training gate: auto-detect LMS certificates via shared SSO
gregv Aug 18, 2026
4baeaf8
Merge pull request #345 from opportunity-hack/feature/judge-lms-train…
gregv Aug 18, 2026
5884f4a
Admin nonprofit applications: readable review table with expandable i…
gregv Aug 19, 2026
934f5cc
Docs: nonprofit application review table contract in CLAUDE.md
gregv Aug 19, 2026
8f5f317
Dietary restrictions: shared dropdown across all four application forms
gregv Aug 19, 2026
8c6eaa4
Docs: DietaryRestrictionsSelect contract in CLAUDE.md
gregv Aug 19, 2026
9a74600
Merge pull request #349 from opportunity-hack/feature/dietary-restric…
gregv Aug 19, 2026
4374c1b
Merge pull request #348 from opportunity-hack/feature/admin-nonprofit…
gregv Aug 19, 2026
6279952
Meals: times-only mode — publish meal times without hacker item selec…
gregv Aug 19, 2026
200e953
Meals: show the meal schedule to in-person mentors, judges, and volun…
gregv Aug 19, 2026
9894445
Merge pull request #350 from opportunity-hack/feature/meals-schedule-…
gregv Aug 21, 2026
dab4aa1
bold pls
gregv Aug 21, 2026
e002540
extra wording
gregv Aug 21, 2026
7150bf0
judge app small changes
gregv Aug 21, 2026
a8947df
Align pending-review messaging across judge/mentor/volunteer apps
gregv Aug 21, 2026
e93f499
Stop application forms from sending isSelected (approval reset bug)
gregv Aug 21, 2026
1ee5066
Merge pull request #351 from opportunity-hack/fix/application-forms-d…
gregv Aug 21, 2026
e07dc60
Add volunteer job board: /jobs pages, application form, and admin
gregv Aug 22, 2026
a76a6b4
Merge pull request #352 from opportunity-hack/feature/volunteer-job-b…
gregv Aug 23, 2026
8320ead
Link /jobs from NavBar, /about, and onboarding; kinder duration wording
gregv Aug 23, 2026
123f094
Merge pull request #353 from opportunity-hack/feature/jobs-cross-links
gregv Aug 23, 2026
4339c31
Admin judge review: inline intro-video player + LMS training status
gregv Aug 29, 2026
108d063
Merge pull request #354 from opportunity-hack/feature/admin-judge-tra…
gregv Aug 29, 2026
232789e
Admin Email tab: top-level email UI, Resend broadcasts + batch sends,…
gregv Aug 30, 2026
7acd461
Merge pull request #355 from opportunity-hack/feat/admin-email-broadc…
gregv Aug 30, 2026
d518be1
Judge application: require a public photo, frame it so it isn't missed
gregv Sep 1, 2026
91df71a
Merge pull request #356 from opportunity-hack/feat/judge-photo-required
gregv Sep 1, 2026
1ab3598
Contact page: dedupe inquiry types, guide-first prompt for mentor/judge
gregv Sep 1, 2026
3e382da
Merge pull request #357 from opportunity-hack/fix/contact-inquiry-types
gregv Sep 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 55 additions & 5 deletions CLAUDE.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion next-sitemap.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
Expand Down Expand Up @@ -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";
Expand Down
171 changes: 171 additions & 0 deletions src/components/ApplicationForm/DietaryRestrictionsSelect.js
Original file line number Diff line number Diff line change
@@ -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 (
<Box sx={sx}>
<FormControl fullWidth required={required}>
<InputLabel id={labelId}>{label}</InputLabel>
<Select
labelId={labelId}
id="dietary-restrictions"
multiple
value={selected}
onChange={handleSelectChange}
label={label}
MenuProps={MenuProps}
renderValue={(vals) => (
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}>
{vals.map((v) => (
<Chip
key={v}
size="small"
label={v === "Other" && other ? other : v}
/>
))}
</Box>
)}
>
{DIETARY_RESTRICTION_OPTIONS.map((option) => (
<MenuItem key={option} value={option}>
<Checkbox checked={selected.includes(option)} size="small" />
<ListItemText primary={option} />
</MenuItem>
))}
</Select>
{helperText && <FormHelperText>{helperText}</FormHelperText>}
</FormControl>
{selected.includes("Other") && (
<TextField
size="small"
fullWidth
label="Tell us more about your dietary needs"
placeholder="e.g. severe garlic allergy, low-sodium"
value={other}
onChange={handleOtherChange}
sx={{ mt: 1.5 }}
/>
)}
</Box>
);
};

export default DietaryRestrictionsSelect;
29 changes: 19 additions & 10 deletions src/components/ApplicationForm/Hacker/LocationDemographicsStep.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -232,28 +238,31 @@ const LocationDemographicsStep = ({

{/* Only show dietary restrictions for non-online events */}
{!eventData?.isOnlineEvent && (
<TextField
label="Dietary Restrictions (Optional)"
name="dietaryRestrictions"
fullWidth
<DietaryRestrictionsSelect
value={formData.dietaryRestrictions || ""}
onChange={handleChange}
sx={{ mb: 3 }}
helperText="Please let us know about any dietary restrictions for in-person attendees"
onChange={(next) =>
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 ? (
<MealSchedule
meals={eventData.constraints.meals}
note={eventData.constraints.meals_note || ""}
/>
) : (
<MealMenu
meals={eventData.constraints.meals}
selections={formData.mealSelections || {}}
onChange={(next) =>
setFormData((prev) => ({ ...prev, mealSelections: next }))
}
/>
)}
))}
</Box>
</Box>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<LocationDemographicsStep
formData={{}}
setFormData={jest.fn()}
handleChange={jest.fn()}
eventData={eventData}
/>,
);

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();
});
});
Loading