diff --git a/docs/TRACKING.md b/docs/TRACKING.md
index 7887cbe..d462af9 100644
--- a/docs/TRACKING.md
+++ b/docs/TRACKING.md
@@ -37,7 +37,7 @@ both sides land on one person.
| Event | Side | Properties | Fires from |
| --- | --- | --- | --- |
-| `cta_clicked` | client | `cta` (`"cadastro"`, `"dashboard"`), `location` (`header`, `hero`, `jornada`, `jornada_colosseum`, `sticky`, `pre_registro`), `edition` on the dashboard CTA | `TrackedCta` on the campaign LP and in the header dock, `MobileCtaBar`; the dashboard CTA is on the edition page and on the `/pre-registro` journey |
+| `cta_clicked` | client | `cta` (`"cadastro"`, `"dashboard"`, `"startup"`, `"perk_helius"`), `location` (`header`, `hero`, `jornada`, `jornada_colosseum`, `sticky`, `pre_registro`), `edition` on the dashboard CTA | `TrackedCta` on the campaign LP and in the header dock, `MobileCtaBar`; the dashboard CTA is on the edition page and on the `/pre-registro` journey |
| `campaign_link_clicked` | client | `target` (`colosseum`, `colosseum_arena`, `colosseum_site`, `trilha_brasil`, `whatsapp`, …), `location` (`lp`, `lp_colosseum`, `recursos`, `faq`, `pre_registro`, `dashboard`, `startup_form`), `edition` on the dashboard panel and on the submission notice | `TrackedCta` on the LP, `TrackedLink` on `/pre-registro`, `ExternalSubmissionPanel`, `SubmissionNotice` on the startup form and the Colosseum dashboard |
| `auth_provider_clicked` | client | `provider` (`google`, `github`, `email`, …) | `AuthForm`, on any sign-in attempt |
| `auth_failed` | client | `provider`, `reason` (`oauth_request_failed`, `otp_request_failed`, `otp_verify_failed`, or the callback error) | `AuthForm` |
@@ -52,6 +52,9 @@ both sides land on one person.
| `startup_form_error` | client | `field`, `step` | `StartupForm`, on a rejected submit |
| `startup_registered` | server | `has_vertical`, `stage` | `saveStartup`, on the first completion only, never on a re-save |
| `startup_submission_saved` | server | `edition`, `status` (`not_started`, `draft`, `submitted` or null), `has_repo`, `has_pitch_video` | `saveStartup`, on every successful save of step 4 (the Colosseum submission) |
+| `perk_form_viewed` | client | `perk` (`helius`), `editing` | `PerkForm` mount on `/perks/helius` |
+| `perk_form_error` | client | `perk`, `field` | `PerkForm`, on a rejected submit |
+| `perk_applied` | server | `perk` | `applyForHeliusPerk`, on the first application only, never on an edit |
| `team_created` | client | `edition` | `NewTeamForm`, after `create_team_with_leader` succeeds |
| `member_invited` | server | `edition`, `team_id`, `via` (`email`, `board`), `has_account` | `addMemberToTeam` (team page form and team-up board invite) |
| `invite_accepted` | client | `edition`, `team_id` | `PendingInviteActions` on the team page |
@@ -71,7 +74,8 @@ PostHog under the `2026-05-30` defaults; there is no pageview component.
Marketing keeps its contact base in RD Station Marketing. The Colosseum
registration flow (`src/app/(public)/pre-registro/actions.ts`) and the startup
-form (`src/app/(public)/startup-registration/actions.ts`) post a
+form (`src/app/(public)/startup-registration/actions.ts`) and the Helius perk
+form (`src/app/(public)/perks/helius/actions.ts`) post a
conversion event through `sendRdConversion()` (`src/lib/rd-station.ts`) to
`POST https://api.rd.services/platform/conversions`, authenticated with
`RD_STATION_API_KEY` (no-op while unset). It runs in `after()` and is not
@@ -79,7 +83,7 @@ consent-gated: it mirrors the registration the person just submitted, not
behavioural analytics.
The contact key is the account e-mail (`state.email`, never form data). A
-conversion for an existing e-mail updates that contact, so the four events are
+conversion for an existing e-mail updates that contact, so the five events are
one upsert each with the fields that changed. Every event carries the tag
`global_2026_cadastro_plataforma`.
@@ -89,6 +93,7 @@ one upsert each with the fields that changed. Every event carries the tag
| Confirmação Colosseum | `global_2026_confirmacao_colosseum` | `confirmColosseumRegistration`, on the attestation |
| Formulário "Sobre você" | `global_2026_formulario_sobre_voce` | `saveInterest`, when the form is completed |
| Cadastro de startup | `global_2026_startup_cadastro` | `saveStartup` (`/startup-registration`), first completion only |
+| Perk Helius | `global_2026_perk_helius` | `applyForHeliusPerk` (`/perks/helius`), first application only |
| RD field | Source |
| --- | --- |
diff --git a/src/app/(app)/admin/page.tsx b/src/app/(app)/admin/page.tsx
index 7b14f76..ada4983 100644
--- a/src/app/(app)/admin/page.tsx
+++ b/src/app/(app)/admin/page.tsx
@@ -57,6 +57,17 @@ export default async function AdminPage() {
}
}
+ // Perks are not tied to an edition, so only global admins see them.
+ let heliusApplications = 0;
+ if (supabase && !scoped) {
+ const { count, error: perkError } = await supabase
+ .from("perk_applications")
+ .select("id", { count: "exact", head: true })
+ .eq("perk", "helius");
+ if (perkError) logQueryError("admin.index.perks", perkError);
+ heliusApplications = count ?? 0;
+ }
+
return (
@@ -123,6 +134,28 @@ export default async function AdminPage() {
/>
)}
+
+ {!scoped && (
+
+
Perks
+
+
+
+
Helius
+
/perks/helius
+
+
+
+ {heliusApplications} {heliusApplications === 1 ? "aplicação" : "aplicações"}
+
+
+ Baixar CSV
+
+
+
+
+
+ )}
);
diff --git a/src/app/(app)/admin/perks/export/route.ts b/src/app/(app)/admin/perks/export/route.ts
new file mode 100644
index 0000000..399bc54
--- /dev/null
+++ b/src/app/(app)/admin/perks/export/route.ts
@@ -0,0 +1,69 @@
+import { NextResponse, type NextRequest } from "next/server";
+import { requireAdmin } from "@/lib/roles";
+import { createServiceRoleClient } from "@/lib/supabase/server";
+import { unwrap } from "@/lib/supabase/unwrap";
+import { toCsv, type CsvCell } from "@/lib/csv";
+import { formatBrt } from "@/lib/dates";
+import type { Perk } from "@/types/db";
+
+export const dynamic = "force-dynamic";
+
+const PERKS: readonly Perk[] = ["helius"];
+// PostgREST caps every response at 1,000 rows whatever range is asked for.
+const PAGE = 1000;
+
+type Row = {
+ project_name: string;
+ project_summary: string;
+ usage_plan: string;
+ created_at: string;
+ updated_at: string;
+ user: { full_name: string | null; email: string; whatsapp: string | null } | null;
+};
+
+const HEADER = ["nome", "email", "whatsapp", "projeto", "resumo", "uso_da_helius", "enviado_em", "atualizado_em"];
+
+export async function GET(request: NextRequest) {
+ const gate = await requireAdmin();
+ if (!gate.ok) {
+ return NextResponse.json({ error: "Sem permissão." }, { status: gate.reason === "unauthenticated" ? 401 : 403 });
+ }
+ const perk = request.nextUrl.searchParams.get("perk");
+ if (!PERKS.includes(perk as Perk)) {
+ return NextResponse.json({ error: "Perk inválido." }, { status: 400 });
+ }
+
+ const supabase = await createServiceRoleClient();
+ const rows: Row[] = [];
+ for (let from = 0; ; from += PAGE) {
+ const result = await supabase
+ .from("perk_applications")
+ .select("project_name, project_summary, usage_plan, created_at, updated_at, user:users(full_name, email, whatsapp)")
+ .eq("perk", perk as Perk)
+ .order("created_at", { ascending: true })
+ .order("id", { ascending: true })
+ .range(from, from + PAGE - 1);
+ const page = (unwrap(result, "admin.perks.export") as unknown as Row[] | null) ?? [];
+ rows.push(...page);
+ if (page.length < PAGE) break;
+ }
+
+ const cells: CsvCell[][] = rows.map((r) => [
+ r.user?.full_name,
+ r.user?.email,
+ r.user?.whatsapp,
+ r.project_name,
+ r.project_summary,
+ r.usage_plan,
+ formatBrt(r.created_at),
+ formatBrt(r.updated_at),
+ ]);
+ const day = new Date().toISOString().slice(0, 10);
+ return new NextResponse(toCsv(HEADER, cells), {
+ headers: {
+ "Content-Type": "text/csv; charset=utf-8",
+ "Content-Disposition": `attachment; filename="perk-${perk}-${day}.csv"`,
+ "Cache-Control": "no-store",
+ },
+ });
+}
diff --git a/src/app/(public)/perks/helius/actions.ts b/src/app/(public)/perks/helius/actions.ts
new file mode 100644
index 0000000..3cbdc87
--- /dev/null
+++ b/src/app/(public)/perks/helius/actions.ts
@@ -0,0 +1,71 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { redirect } from "next/navigation";
+import { createServerSupabaseClient } from "@/lib/supabase/server";
+import { logQueryError } from "@/lib/supabase/unwrap";
+import { requireUser } from "@/lib/user-state";
+import { track } from "@/lib/analytics-server";
+import { sendRdConversion } from "@/lib/rd-station";
+import { HELIUS_PERK_PATH, PERK } from "./constants";
+import { validatePerkApplication, type PerkField, type PerkFields } from "./application";
+
+const SAVE_ERROR = "Não foi possível enviar. Tente novamente.";
+const FIELDS = ["full_name", "whatsapp", "project_name", "project_summary", "usage_plan"] as const;
+
+type PerkError = { ok: false; error: string; field: PerkField };
+
+export async function applyForHeliusPerk(
+ _prevState: { ok: boolean; error?: string },
+ formData: FormData,
+): Promise {
+ const state = await requireUser();
+
+ const fields: PerkFields = {};
+ for (const name of FIELDS) {
+ const v = formData.get(name);
+ fields[name] = typeof v === "string" ? v : null;
+ }
+ const validation = validatePerkApplication(fields);
+ if (!validation.ok) return validation;
+
+ // Own-row writes behind RLS, like saveStartup.
+ const supabase = await createServerSupabaseClient();
+
+ const { error: userError } = await supabase.from("users").update(validation.user).eq("id", state.userId);
+ if (userError) {
+ logQueryError("perk.updateProfile", userError);
+ return { ok: false, error: SAVE_ERROR, field: "server" };
+ }
+
+ const { data: prior, error: priorError } = await supabase
+ .from("perk_applications")
+ .select("id")
+ .eq("perk", PERK)
+ .eq("user_id", state.userId)
+ .maybeSingle();
+ if (priorError) logQueryError("perk.prior", priorError);
+
+ const { error } = await supabase
+ .from("perk_applications")
+ .upsert({ perk: PERK, user_id: state.userId, ...validation.application }, { onConflict: "perk,user_id" });
+ if (error) {
+ logQueryError("perk.upsert", error);
+ return { ok: false, error: SAVE_ERROR, field: "server" };
+ }
+
+ // An edit to a sent application must not count as a new one.
+ if (!prior) {
+ track(state.userId, "perk_applied", { perk: PERK });
+ sendRdConversion({
+ identifier: "perk_helius",
+ email: state.email,
+ name: validation.user.full_name,
+ whatsapp: validation.user.whatsapp,
+ fields: {},
+ });
+ }
+
+ revalidatePath(HELIUS_PERK_PATH);
+ redirect(`${HELIUS_PERK_PATH}?enviado=1`);
+}
diff --git a/src/app/(public)/perks/helius/application.ts b/src/app/(public)/perks/helius/application.ts
new file mode 100644
index 0000000..1105a4f
--- /dev/null
+++ b/src/app/(public)/perks/helius/application.ts
@@ -0,0 +1,50 @@
+import { sanitizeText } from "@/lib/security";
+import { normalizeWhatsapp } from "@/lib/phone";
+
+export type PerkField = "full_name" | "whatsapp" | "project_name" | "project_summary" | "usage_plan" | "server";
+
+export type PerkFields = Partial, string | null>>;
+
+export const PERK_LIMITS = {
+ full_name: 120,
+ project_name: 120,
+ project_summary: 1000,
+ usage_plan: 1000,
+} as const;
+
+export type PerkValidation =
+ | {
+ ok: true;
+ user: { full_name: string; whatsapp: string };
+ application: { project_name: string; project_summary: string; usage_plan: string };
+ }
+ | { ok: false; error: string; field: PerkField };
+
+/** Every field is required: the team vets each application by hand. */
+export function validatePerkApplication(fields: PerkFields): PerkValidation {
+ const fullName = sanitizeText(fields.full_name, PERK_LIMITS.full_name);
+ if (!fullName) return { ok: false, error: "Informe seu nome.", field: "full_name" };
+
+ const whatsappText = sanitizeText(fields.whatsapp, 40);
+ if (!whatsappText) return { ok: false, error: "Informe seu WhatsApp.", field: "whatsapp" };
+ // Same loose shape check as the startup form: formats vary, but text must
+ // not pass as a number.
+ const digits = whatsappText.replace(/\D/g, "");
+ const whatsapp = digits.length >= 10 && digits.length <= 14 ? normalizeWhatsapp(whatsappText) : null;
+ if (!whatsapp) return { ok: false, error: "Informe um WhatsApp válido, com DDD.", field: "whatsapp" };
+
+ const projectName = sanitizeText(fields.project_name, PERK_LIMITS.project_name);
+ if (!projectName) return { ok: false, error: "Informe o nome do projeto.", field: "project_name" };
+
+ const summary = sanitizeText(fields.project_summary, PERK_LIMITS.project_summary);
+ if (!summary) return { ok: false, error: "Conte em poucas linhas o que o projeto faz.", field: "project_summary" };
+
+ const usage = sanitizeText(fields.usage_plan, PERK_LIMITS.usage_plan);
+ if (!usage) return { ok: false, error: "Conte onde e por que você usaria a Helius.", field: "usage_plan" };
+
+ return {
+ ok: true,
+ user: { full_name: fullName, whatsapp },
+ application: { project_name: projectName, project_summary: summary, usage_plan: usage },
+ };
+}
diff --git a/src/app/(public)/perks/helius/constants.ts b/src/app/(public)/perks/helius/constants.ts
new file mode 100644
index 0000000..3cc2a7a
--- /dev/null
+++ b/src/app/(public)/perks/helius/constants.ts
@@ -0,0 +1,30 @@
+export const HELIUS_PERK_PATH = "/perks/helius";
+export const PERK = "helius" as const;
+
+// Draft copy from the perk brief: the owner rewrites it here, nowhere else.
+export const HERO = {
+ kicker: "Perk Superteam × Helius",
+ title: "Infra da Helius para o seu",
+ highlight: "projeto na Solana",
+ lead: "RPC, APIs e webhooks da Helius, a principal plataforma de desenvolvimento da Solana, sem custo para builders da comunidade Superteam Brasil.",
+ cta: "Entrar para aplicar →",
+} as const;
+
+export const TIERS = [
+ {
+ name: "Developer",
+ offer: "1 ano grátis",
+ detail: "Para quem está construindo: limites maiores de RPC e as APIs avançadas da Helius.",
+ },
+ {
+ name: "Business",
+ offer: "3 meses grátis",
+ detail: "Para startups em produção que precisam de mais volume e suporte de infraestrutura.",
+ },
+] as const;
+
+export const HOW_IT_WORKS = [
+ "Você conta sobre o projeto e como usaria a Helius.",
+ "O time da Superteam Brasil analisa cada aplicação e define o plano.",
+ "Quem for aprovado recebe um código de ativação para usar no painel da Helius.",
+] as const;
diff --git a/src/app/(public)/perks/helius/page.tsx b/src/app/(public)/perks/helius/page.tsx
new file mode 100644
index 0000000..f700486
--- /dev/null
+++ b/src/app/(public)/perks/helius/page.tsx
@@ -0,0 +1,202 @@
+import Link from "next/link";
+import { Card } from "@/components/ui/card";
+import { TrackedCta } from "@/components/ui/tracked-cta";
+import { createServerSupabaseClient } from "@/lib/supabase/server";
+import { logQueryError, unwrap } from "@/lib/supabase/unwrap";
+import { resolveAuthenticatedUserState } from "@/lib/user-state";
+import { DAY_MONTH, stripPeriods } from "@/lib/dates";
+import { WHATSAPP_COMMUNITY_URL } from "../../pre-registro/constants";
+import { TrackedLink } from "../../pre-registro/tracked-link";
+import { PerkForm, type ProjectPrefill } from "./perk-form";
+import { HELIUS_PERK_PATH, HERO, HOW_IT_WORKS, PERK, TIERS } from "./constants";
+import { PERK_LIMITS } from "./application";
+import type { PerkApplication } from "@/types/db";
+
+export const metadata = {
+ title: "Perk Helius | Superteam Brasil",
+ description: HERO.lead,
+};
+
+export const dynamic = "force-dynamic";
+
+const FORM_ID = "aplicacao";
+
+const CTA_CLASS =
+ "mt-8 inline-block whitespace-nowrap rounded-full bg-yellow px-6 py-2.5 text-sm font-bold text-green-dark transition-transform duration-(--dur-instant) ease-mola hover:-translate-y-0.5";
+
+async function loadApplication(userId: string) {
+ const supabase = await createServerSupabaseClient();
+ const result = await supabase
+ .from("perk_applications")
+ .select("*")
+ .eq("perk", PERK)
+ .eq("user_id", userId)
+ .maybeSingle();
+ return unwrap(result, "perks.helius.loadApplication") as PerkApplication | null;
+}
+
+/**
+ * A first-time applicant who already told us about a project, on the startup
+ * form or on "Sobre você", starts with it filled in. Both fields come from
+ * the same source so a startup name never sits beside another project's
+ * summary. A failed read only costs the prefill.
+ */
+async function loadProjectPrefill(userId: string): Promise {
+ const supabase = await createServerSupabaseClient();
+ const [startupResult, interestResult] = await Promise.all([
+ supabase.from("startups").select("name, one_liner, description").eq("user_id", userId).maybeSingle(),
+ supabase
+ .from("campaign_interest")
+ .select("project_name, one_liner")
+ .eq("user_id", userId)
+ .not("project_name", "is", null)
+ .order("updated_at", { ascending: false })
+ .limit(1)
+ .maybeSingle(),
+ ]);
+ if (startupResult.error) logQueryError("perks.helius.prefillStartup", startupResult.error);
+ if (interestResult.error) logQueryError("perks.helius.prefillInterest", interestResult.error);
+
+ const startup = startupResult.data;
+ const interest = interestResult.data;
+ const source = startup?.name
+ ? { name: startup.name, summary: startup.description ?? startup.one_liner }
+ : interest?.project_name
+ ? { name: interest.project_name, summary: interest.one_liner }
+ : null;
+ if (!source) return null;
+ return {
+ project_name: source.name.slice(0, PERK_LIMITS.project_name),
+ project_summary: source.summary?.slice(0, PERK_LIMITS.project_summary) ?? null,
+ };
+}
+
+function Hero({ cta }: { cta: "auth" | "form" | null }) {
+ return (
+
+ {HERO.kicker}
+
+ {HERO.title}{" "}
+
+ {HERO.highlight}
+
+
+ {HERO.lead}
+
+
+ {TIERS.map((tier) => (
+
+ Plano {tier.name}
+ {tier.offer}
+ {tier.detail}
+
+ ))}
+
+
+
+ {HOW_IT_WORKS.map((step, i) => (
+
+
+ {i + 1}
+
+ {step}
+
+ ))}
+
+
+ {cta === "auth" && (
+
+ {HERO.cta}
+
+ )}
+ {cta === "form" && (
+
+ Aplicar agora ↓
+
+ )}
+
+ );
+}
+
+function Received({ application, sent }: { application: PerkApplication; sent: boolean }) {
+ const day = stripPeriods(DAY_MONTH.format(new Date(application.created_at)));
+ return (
+
+
+
+ ✓
+ Aplicação recebida
+
+ {sent && (
+
+ Salvo
+
+ )}
+
+
+ {application.project_name}
+
+
+ Recebemos sua aplicação em {day}. O time da Superteam Brasil analisa cada pedido e, se for aprovado, fala com
+ você pelo e-mail ou WhatsApp que você informou, com o código de ativação da Helius.
+
+
+
+ Editar respostas
+
+
+ Entrar no grupo do WhatsApp
+
+
+
+ );
+}
+
+export default async function HeliusPerkPage({
+ searchParams,
+}: {
+ searchParams: Promise>;
+}) {
+ const [state, params] = await Promise.all([resolveAuthenticatedUserState(), searchParams]);
+ const application = state ? await loadApplication(state.userId) : null;
+ const prefill = state && !application ? await loadProjectPrefill(state.userId) : null;
+ const editing = params.editar === "1";
+ const showForm = Boolean(state) && (!application || editing);
+
+ return (
+
+
+ {/* The confirmation comes first so it is on screen after sending. */}
+ {state && application && !editing &&
}
+
+
+
+ {state && showForm && (
+
+ )}
+
+
+ );
+}
diff --git a/src/app/(public)/perks/helius/perk-form.tsx b/src/app/(public)/perks/helius/perk-form.tsx
new file mode 100644
index 0000000..60c7780
--- /dev/null
+++ b/src/app/(public)/perks/helius/perk-form.tsx
@@ -0,0 +1,169 @@
+"use client";
+
+import { useActionState, useEffect, useState, startTransition, type FormEvent } from "react";
+import { Button } from "@/components/ui/button";
+import { Input, Label, Textarea } from "@/components/ui/input";
+import { trackClient } from "@/lib/analytics-browser";
+import { applyForHeliusPerk } from "./actions";
+import { PERK } from "./constants";
+import { PERK_LIMITS, type PerkField } from "./application";
+import type { PerkApplication, User } from "@/types/db";
+
+type State = { ok: false; error: string; field: PerkField };
+
+export type ProjectPrefill = { project_name: string; project_summary: string | null };
+
+const LEGEND = "mb-3 font-mono text-xs font-bold uppercase tracking-[0.2em] text-emerald";
+
+export function PerkForm({
+ profile,
+ email,
+ application,
+ prefill,
+}: {
+ profile: User | null;
+ email: string;
+ application: PerkApplication | null;
+ prefill: ProjectPrefill | null;
+}) {
+ const [state, formAction, pending] = useActionState(
+ async (prev: State, formData: FormData) => {
+ // A successful send redirects from the action, so only an error comes back.
+ const result = await applyForHeliusPerk(prev, formData);
+ if (!result) return prev;
+ trackClient("perk_form_error", { perk: PERK, field: result.field });
+ return result;
+ },
+ { ok: false, error: "", field: "server" } as State,
+ );
+
+ useEffect(() => {
+ trackClient("perk_form_viewed", { perk: PERK, editing: Boolean(application) });
+ }, [application]);
+
+ // Same reason as StartupForm: a manual dispatch keeps the answers on a
+ // validation error.
+ const [edited, setEdited] = useState(false);
+ const submit = (e: FormEvent) => {
+ e.preventDefault();
+ setEdited(false);
+ const formData = new FormData(e.currentTarget);
+ startTransition(() => formAction(formData));
+ };
+
+ const errorField = state.error && !edited ? state.field : null;
+ useEffect(() => {
+ if (!errorField || errorField === "server") return;
+ const el = document.getElementById(errorField);
+ el?.scrollIntoView({ block: "center", behavior: "smooth" });
+ el?.focus({ preventScroll: true });
+ }, [errorField, state]);
+ const invalid = (field: PerkField) =>
+ errorField === field
+ ? {
+ "aria-invalid": true as const,
+ "aria-describedby": `${field}-error`,
+ className: "border-red-500 focus:border-red-500 focus:ring-red-500/30",
+ }
+ : {};
+ const fieldError = (field: PerkField) =>
+ errorField === field ? (
+
+ {state.error}
+
+ ) : null;
+
+ return (
+
+ );
+}
diff --git a/src/lib/__tests__/perk-application.test.ts b/src/lib/__tests__/perk-application.test.ts
new file mode 100644
index 0000000..3514fef
--- /dev/null
+++ b/src/lib/__tests__/perk-application.test.ts
@@ -0,0 +1,49 @@
+import { describe, it, expect } from "vitest";
+import { PERK_LIMITS, validatePerkApplication } from "@/app/(public)/perks/helius/application";
+
+const valid = {
+ full_name: " Ana Souza ",
+ whatsapp: "(11) 91234-5678",
+ project_name: "Pix Bridge",
+ project_summary: "Ponte entre Pix e USDC na Solana.",
+ usage_plan: "Webhooks para confirmar depósitos e RPC para o app.",
+};
+
+describe("validatePerkApplication", () => {
+ it("accepts a complete application and normalizes the WhatsApp", () => {
+ const result = validatePerkApplication(valid);
+ expect(result).toEqual({
+ ok: true,
+ user: { full_name: "Ana Souza", whatsapp: "+5511912345678" },
+ application: {
+ project_name: "Pix Bridge",
+ project_summary: "Ponte entre Pix e USDC na Solana.",
+ usage_plan: "Webhooks para confirmar depósitos e RPC para o app.",
+ },
+ });
+ });
+
+ it.each(["full_name", "whatsapp", "project_name", "project_summary", "usage_plan"] as const)(
+ "refuses a blank %s and points at it",
+ (field) => {
+ const result = validatePerkApplication({ ...valid, [field]: " " });
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.field).toBe(field);
+ },
+ );
+
+ it("refuses text where the WhatsApp number goes", () => {
+ const result = validatePerkApplication({ ...valid, whatsapp: "meu zap" });
+ expect(result).toMatchObject({ ok: false, field: "whatsapp" });
+ });
+
+ it("keeps a foreign number with its country code", () => {
+ const result = validatePerkApplication({ ...valid, whatsapp: "+1 415 555 0100" });
+ expect(result).toMatchObject({ ok: true, user: { whatsapp: "+14155550100" } });
+ });
+
+ it("cuts long answers at the column limit", () => {
+ const result = validatePerkApplication({ ...valid, usage_plan: "a".repeat(PERK_LIMITS.usage_plan + 50) });
+ expect(result.ok && result.application.usage_plan.length).toBe(PERK_LIMITS.usage_plan);
+ });
+});
diff --git a/src/lib/__tests__/routes.test.ts b/src/lib/__tests__/routes.test.ts
index 3e237d6..3853574 100644
--- a/src/lib/__tests__/routes.test.ts
+++ b/src/lib/__tests__/routes.test.ts
@@ -31,6 +31,11 @@ describe("isPublicRoute", () => {
expect(isPublicRoute("/auth/callback")).toBe(true);
});
+ it("keeps the perk pages public", () => {
+ expect(isPublicRoute("/perks/helius")).toBe(true);
+ expect(isPublicRoute("/perksx")).toBe(false);
+ });
+
it("keeps the legal pages public", () => {
expect(isPublicRoute("/privacidade")).toBe(true);
expect(isPublicRoute("/termos")).toBe(true);
diff --git a/src/lib/rd-station.ts b/src/lib/rd-station.ts
index d28eb58..f05d51e 100644
--- a/src/lib/rd-station.ts
+++ b/src/lib/rd-station.ts
@@ -15,6 +15,7 @@ export const RD_CONVERSION_IDENTIFIERS = {
confirmacao: "global_2026_confirmacao_colosseum",
formulario: "global_2026_formulario_sobre_voce",
startup: "global_2026_startup_cadastro",
+ perk_helius: "global_2026_perk_helius",
} as const;
export const RD_TAGS = ["global_2026_cadastro_plataforma"] as const;
diff --git a/src/lib/routes.ts b/src/lib/routes.ts
index 68cffee..792a6fa 100644
--- a/src/lib/routes.ts
+++ b/src/lib/routes.ts
@@ -6,6 +6,7 @@ const PUBLIC_ROUTES = [
"/termos",
"/pre-registro",
"/startup-registration",
+ "/perks",
"/guias",
];
diff --git a/src/types/db.ts b/src/types/db.ts
index 3b02b0c..b8b0219 100644
--- a/src/types/db.ts
+++ b/src/types/db.ts
@@ -297,6 +297,19 @@ export type Startup = {
updated_at: string;
};
+export type Perk = "helius";
+
+export type PerkApplication = {
+ id: string;
+ perk: Perk;
+ user_id: string;
+ project_name: string;
+ project_summary: string;
+ usage_plan: string;
+ created_at: string;
+ updated_at: string;
+};
+
export type PlatformRole = {
id: string;
user_id: string;
diff --git a/supabase/migrations/00068_perk_applications.sql b/supabase/migrations/00068_perk_applications.sql
new file mode 100644
index 0000000..30313ac
--- /dev/null
+++ b/supabase/migrations/00068_perk_applications.sql
@@ -0,0 +1,36 @@
+-- Partner perks (/perks/helius): builders apply on the site and the
+-- Superteam Brasil team vets each request by hand before sending the
+-- partner's activation code. One application per person per perk, editable
+-- until it is reviewed. Name and WhatsApp live on users, as in the startup
+-- form; e-mail comes from the login.
+create table public.perk_applications (
+ id uuid primary key default gen_random_uuid(),
+ perk text not null check (perk in ('helius')),
+ user_id uuid not null references public.users(id) on delete cascade,
+ project_name text not null,
+ project_summary text not null,
+ usage_plan text not null,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+ unique (perk, user_id)
+);
+
+create trigger perk_applications_touch_updated_at
+ before update on public.perk_applications
+ for each row execute function public.touch_updated_at();
+
+alter table public.perk_applications enable row level security;
+
+create policy perk_applications_select_own on public.perk_applications
+ for select using (user_id = auth.uid());
+create policy perk_applications_insert_own on public.perk_applications
+ for insert with check (user_id = auth.uid());
+create policy perk_applications_update_own on public.perk_applications
+ for update using (user_id = auth.uid()) with check (user_id = auth.uid());
+
+-- The default privileges from 00009/00010 also hand authenticated DELETE and
+-- TRUNCATE, and TRUNCATE ignores RLS: revoke everything, then grant back only
+-- what the form needs. No delete for anyone but the service role.
+revoke all on public.perk_applications from anon, authenticated;
+grant select, insert, update on public.perk_applications to authenticated;
+grant all on public.perk_applications to service_role;