Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
35 changes: 35 additions & 0 deletions app/api/register-form/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Session } from "next-auth";
import { withAuth } from "@/lib/protectedRoute";
import { prisma } from "@/prisma/prisma";
import { syncUserDataToHubSpot } from "@/server/services/hubspotUserData";
import { isTeam1Event } from "@/lib/events/team1";

type UserConsentsInput = {
notifications?: unknown;
Expand Down Expand Up @@ -41,6 +42,40 @@ export const POST = withAuth(async (
try {
const body = await req.json();
const { user_consents, ...registerData } = body ?? {};

// Team1-organized events require explicit sharing consent unless the user
// has already granted it on their profile. Enforce here so a crafted
// client request can't bypass the registration form's required check.
const hackathonId = registerData?.hackathon_id;
if (session.user?.email && typeof hackathonId === "string" && hackathonId) {
const [hackathon, user] = await Promise.all([
prisma.hackathon.findUnique({
where: { id: hackathonId },
select: { organizers: true, cohosts: true },
}),
prisma.user.findUnique({
where: { email: session.user.email },
select: { consent_sharing: true },
}),
]);
const isTeam1 = hackathon ? isTeam1Event(hackathon) : false;
const userHasConsent = user?.consent_sharing === true;
const incomingConsent =
(user_consents as UserConsentsInput | undefined)?.consent_sharing;
if (isTeam1 && !userHasConsent && incomingConsent !== true) {
return NextResponse.json(
{
error: {
message:
"Team1 sharing consent is required to register for this event.",
field: "user_consent_sharing",
},
},
{ status: 400 },
);
}
}

if (user_consents && typeof user_consents === "object" && session.user?.email) {
await persistUserConsents(session.user.email, user_consents as UserConsentsInput);
}
Expand Down
150 changes: 38 additions & 112 deletions components/common/grouped-user-consents.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"use client";

import * as React from "react";
import { ChevronDown } from "lucide-react";

import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
Expand All @@ -19,134 +18,61 @@ export type GroupedUserConsentsProps = {
groupLabel: string;
groupHint?: string;
items: GroupedConsentItem[];
defaultExpanded?: boolean;
className?: string;
};

/**
* Renders a group of User-level consent checkboxes with a parent toggle that
* checks/unchecks all children at once and shows an indeterminate state when
* only some children are checked. When only one item is provided, the parent
* wrapper collapses into a single flat checkbox to avoid superfluous nesting.
* Renders a section of User-level consent checkboxes under a static group
* heading. Each item is an independent checkbox — there is no parent toggle
* and no collapse behavior.
*/
export function GroupedUserConsents({
groupLabel,
groupHint,
items,
defaultExpanded = false,
className,
}: GroupedUserConsentsProps) {
const parentInputId = React.useId();
const [expanded, setExpanded] = React.useState(defaultExpanded);
const reactId = React.useId();

if (items.length === 0) return null;

if (items.length === 1) {
const item = items[0];
const id = `${parentInputId}-${item.key}`;
return (
<div className={cn("flex items-start space-x-3", className)}>
<Checkbox
id={id}
checked={item.checked}
onCheckedChange={(value) => item.onCheckedChange(value === true)}
/>
<div className="flex-1">
<label
htmlFor={id}
className="text-sm text-foreground cursor-pointer"
>
{item.label}
</label>
{item.hint ? (
<p className="text-xs text-muted-foreground mt-1">{item.hint}</p>
) : null}
</div>
</div>
);
}

const allChecked = items.every((i) => i.checked);
const noneChecked = items.every((i) => !i.checked);
const parentState: boolean | "indeterminate" = allChecked
? true
: noneChecked
? false
: "indeterminate";

const handleParentChange = () => {
const next = !allChecked;
items.forEach((i) => i.onCheckedChange(next));
};

return (
<div className={cn("space-y-2", className)}>
<div className="flex items-start space-x-3">
<Checkbox
id={parentInputId}
checked={parentState}
onCheckedChange={handleParentChange}
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<label
htmlFor={parentInputId}
className="text-sm text-foreground cursor-pointer"
>
{groupLabel}
</label>
<button
type="button"
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
aria-controls={`${parentInputId}-details`}
aria-label={expanded ? "Collapse details" : "Expand details"}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronDown
className={cn(
"size-4 transition-transform",
expanded && "rotate-180",
)}
/>
</button>
</div>
{groupHint ? (
<p className="text-xs text-muted-foreground mt-1">{groupHint}</p>
) : null}
</div>
<div className={cn("space-y-3", className)}>
<div>
<p className="text-sm text-foreground">{groupLabel}</p>
{groupHint ? (
<p className="text-xs text-muted-foreground mt-1">{groupHint}</p>
) : null}
</div>
{expanded ? (
<div id={`${parentInputId}-details`} className="pl-7 space-y-3">
{items.map((item) => {
const id = `${parentInputId}-${item.key}`;
return (
<div key={item.key} className="flex items-start space-x-3">
<Checkbox
id={id}
checked={item.checked}
onCheckedChange={(value) =>
item.onCheckedChange(value === true)
}
/>
<div className="flex-1">
<label
htmlFor={id}
className="text-sm text-foreground cursor-pointer"
>
{item.label}
</label>
{item.hint ? (
<p className="text-xs text-muted-foreground mt-1">
{item.hint}
</p>
) : null}
</div>
<div className="space-y-3">
{items.map((item) => {
const id = `${reactId}-${item.key}`;
return (
<div key={item.key} className="flex items-start space-x-3">
<Checkbox
id={id}
checked={item.checked}
onCheckedChange={(value) =>
item.onCheckedChange(value === true)
}
/>
<div className="flex-1">
<label
htmlFor={id}
className="text-sm text-foreground cursor-pointer"
>
{item.label}
</label>
{item.hint ? (
<p className="text-xs text-muted-foreground mt-1">
{item.hint}
</p>
) : null}
</div>
);
})}
</div>
) : null}
</div>
);
})}
</div>
</div>
);
}
42 changes: 30 additions & 12 deletions components/hackathons/registration-form/RegisterFormStep3.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,21 @@ interface RegisterFormStep3Props {
lang?: EventsLang;
showNotificationsConsent?: boolean;
showSharingConsent?: boolean;
/** Team1-organized event — sharing consent is mandatory to register. */
requireSharingConsent?: boolean;
}

export function RegisterFormStep3({
isOnlineHackathon,
lang = "en",
showNotificationsConsent = false,
showSharingConsent = false,
requireSharingConsent = false,
}: RegisterFormStep3Props) {
const form = useFormContext<RegisterFormValues>();
const sharingError = form.formState.errors.user_consent_sharing?.message as
| string
| undefined;

const consentItems: GroupedConsentItem[] = [];
if (showNotificationsConsent) {
Expand All @@ -42,11 +48,17 @@ export function RegisterFormStep3({
if (showSharingConsent) {
consentItems.push({
key: "user_consent_sharing",
label: t(lang, "consents.consentSharing.label"),
label:
t(lang, "consents.consentSharing.label") +
(requireSharingConsent ? " *" : ""),
hint: t(lang, "consents.consentSharing.hint"),
checked: form.watch("user_consent_sharing") ?? false,
onCheckedChange: (next) =>
form.setValue("user_consent_sharing", next, { shouldDirty: true }),
onCheckedChange: (next) => {
form.setValue("user_consent_sharing", next, { shouldDirty: true });
if (next && sharingError) {
form.clearErrors("user_consent_sharing");
}
},
});
}

Expand Down Expand Up @@ -88,20 +100,25 @@ export function RegisterFormStep3({
{t(lang, "reg.step3.privacyLink")}
</a> *
</FormLabel>
<FormMessage className="text-zinc-400">
<p className="text-zinc-400 text-sm">
{t(lang, "reg.step3.terms.hint")}
</FormMessage>
</p>
<FormMessage />
</div>
</FormItem>
)}
/>

{consentItems.length > 0 && (
<GroupedUserConsents
groupLabel={t(lang, "consents.group.label")}
groupHint={t(lang, "consents.group.hint")}
items={consentItems}
/>
<div className="space-y-2">
<GroupedUserConsents
groupLabel={t(lang, "consents.group.label")}
items={consentItems}
/>
{sharingError ? (
<p className="text-sm text-red-500">{sharingError}</p>
) : null}
</div>
)}

{/* Only show prohibited items for in-person hackathons */}
Expand All @@ -120,9 +137,10 @@ export function RegisterFormStep3({
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>{t(lang, "reg.step3.prohibited.label")}</FormLabel>
<FormMessage className="text-zinc-400">
<p className="text-zinc-400 text-sm">
{t(lang, "reg.step3.prohibited.hint")}
</FormMessage>
</p>
<FormMessage />
</div>
</FormItem>
)}
Expand Down
32 changes: 32 additions & 0 deletions components/hackathons/registration-form/RegistrationForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import Modal from "@/components/ui/Modal";
import ProcessCompletedDialog from "./ProcessCompletedDialog";
import { useUTMPreservation } from "@/hooks/use-utm-preservation";
import { normalizeEventsLang, t } from "@/lib/events/i18n";
import { isTeam1Event } from "@/lib/events/team1";
import { clearStoredReferralAttribution } from "@/lib/referrals/client";
import {
ReferralFormSection,
Expand Down Expand Up @@ -139,6 +140,13 @@ export function RegisterForm({

// Determine if hackathon is online based on location
const isOnlineHackathon = hackathon?.location?.toLowerCase().includes("online") || false;
// Team1-organized / co-hosted events require the `consent_sharing` opt-in
// unless the user has already granted it on their profile.
const isTeam1 = hackathon
? isTeam1Event({ organizers: hackathon.organizers, cohosts: hackathon.cohosts })
: false;
const requireSharingConsent =
isTeam1 && consentsLoaded && userConsentState.consent_sharing !== true;
Comment thread
Andyvargtz marked this conversation as resolved.
const lang = normalizeEventsLang(hackathon?.content?.language);

const getDefaultValues = () => ({
Expand Down Expand Up @@ -481,11 +489,27 @@ export function RegisterForm({
};
}

if (requireSharingConsent && data.user_consent_sharing !== true) {
errors.user_consent_sharing = {
type: "custom",
message: t(lang, "consents.consentSharing.required"),
};
}


if (Object.keys(errors).length > 0) {
Object.keys(errors).forEach(field => {
form.setError(field as keyof RegisterFormValues, errors[field]);
});
// Bring the first invalid field into view so the user notices the
// feedback even when scrolled to the submit button.
const firstField = Object.keys(errors)[0];
if (typeof window !== "undefined") {
const el = document.querySelector<HTMLElement>(
`[name="${firstField}"], #${CSS.escape(firstField)}`,
);
el?.scrollIntoView({ behavior: "smooth", block: "center" });
}
return;
}
setFormData((prevData) => ({ ...prevData, ...data }));
Expand Down Expand Up @@ -611,6 +635,13 @@ export function RegisterForm({
};
}

if (requireSharingConsent && formValues.user_consent_sharing !== true) {
errors.user_consent_sharing = {
type: "custom",
message: t(lang, "consents.consentSharing.required"),
};
}

if (Object.keys(errors).length > 0) {
(Object.keys(errors) as (keyof RegisterFormValues)[]).forEach(field => {
form.setError(field, errors[field]!);
Expand Down Expand Up @@ -662,6 +693,7 @@ export function RegisterForm({
lang={lang}
showNotificationsConsent={showNotificationsConsent}
showSharingConsent={showSharingConsent}
requireSharingConsent={requireSharingConsent}
/>
)}
<Separator className="border-red-300 dark:border-red-300 mt-4" />
Expand Down
Loading
Loading