From eb52ff93d66ed4a541fa0e195b3f9c6d0fde9950 Mon Sep 17 00:00:00 2001 From: Gabriel Thom <61333600+thomgabriel@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:49:27 -0300 Subject: [PATCH 1/2] feat: Helius perk application form at /perks/helius --- docs/TRACKING.md | 11 +- src/app/(app)/admin/page.tsx | 33 ++++ src/app/(app)/admin/perks/export/route.ts | 69 ++++++++ src/app/(public)/perks/helius/actions.ts | 71 ++++++++ src/app/(public)/perks/helius/application.ts | 50 ++++++ src/app/(public)/perks/helius/constants.ts | 30 ++++ src/app/(public)/perks/helius/page.tsx | 164 ++++++++++++++++++ src/app/(public)/perks/helius/perk-form.tsx | 160 +++++++++++++++++ src/lib/__tests__/perk-application.test.ts | 49 ++++++ src/lib/__tests__/routes.test.ts | 5 + src/lib/rd-station.ts | 1 + src/lib/routes.ts | 1 + src/types/db.ts | 13 ++ .../migrations/00068_perk_applications.sql | 36 ++++ 14 files changed, 690 insertions(+), 3 deletions(-) create mode 100644 src/app/(app)/admin/perks/export/route.ts create mode 100644 src/app/(public)/perks/helius/actions.ts create mode 100644 src/app/(public)/perks/helius/application.ts create mode 100644 src/app/(public)/perks/helius/constants.ts create mode 100644 src/app/(public)/perks/helius/page.tsx create mode 100644 src/app/(public)/perks/helius/perk-form.tsx create mode 100644 src/lib/__tests__/perk-application.test.ts create mode 100644 supabase/migrations/00068_perk_applications.sql diff --git a/docs/TRACKING.md b/docs/TRACKING.md index 7887cbec..d462af97 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 7b14f766..ada49833 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 00000000..399bc548 --- /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 00000000..3cbdc873 --- /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 00000000..1105a4fc --- /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 00000000..3cc2a7a2 --- /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 00000000..de6e5dba --- /dev/null +++ b/src/app/(public)/perks/helius/page.tsx @@ -0,0 +1,164 @@ +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 { 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 } from "./perk-form"; +import { HELIUS_PERK_PATH, HERO, HOW_IT_WORKS, PERK, TIERS } from "./constants"; +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; +} + +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) => ( +
  1. + + {i + 1} + + {step} +
  2. + ))} +
+ + {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 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 && ( +
+ +

+ {application ? "Editar aplicação" : "Aplicação"} +

+

Leva uns dois minutos. Todos os campos são obrigatórios.

+
+ +
+
+
+ )} +
+
+ ); +} 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 00000000..4538fd76 --- /dev/null +++ b/src/app/(public)/perks/helius/perk-form.tsx @@ -0,0 +1,160 @@ +"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 }; + +const LEGEND = "mb-3 font-mono text-xs font-bold uppercase tracking-[0.2em] text-emerald"; + +export function PerkForm({ + profile, + email, + application, +}: { + profile: User | null; + email: string; + application: PerkApplication | 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 ? ( + + ) : null; + + return ( +
setEdited(true)} className="space-y-6"> +
+ Seus dados +
+ + + {fieldError("full_name")} +
+
+
+ + + {fieldError("whatsapp")} +
+
+ + +
+
+
+ +
+ Seu projeto +
+ + + {fieldError("project_name")} +
+
+ +