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
11 changes: 8 additions & 3 deletions docs/TRACKING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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 |
Expand All @@ -71,15 +74,16 @@ 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
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`.

Expand All @@ -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 |
| --- | --- |
Expand Down
33 changes: 33 additions & 0 deletions src/app/(app)/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="px-4 py-12 sm:px-6 lg:px-8">
<div className="mx-auto max-w-5xl">
Expand Down Expand Up @@ -123,6 +134,28 @@ export default async function AdminPage() {
/>
)}
</div>

{!scoped && (
<div className="mt-10">
<p className="font-mono text-[11px] font-bold uppercase tracking-[0.18em] text-emerald">Perks</p>
<Card sticker className="mt-3 p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="font-heading text-xl font-bold">Helius</h2>
<p className="font-mono text-sm tabular-nums text-muted">/perks/helius</p>
</div>
<div className="flex items-center gap-4">
<p className="font-mono text-sm tabular-nums text-muted">
{heliusApplications} {heliusApplications === 1 ? "aplicação" : "aplicações"}
</p>
<a href="/admin/perks/export?perk=helius" className="btn-secondary min-h-11 px-5 py-2 text-sm">
Baixar CSV
</a>
</div>
</div>
</Card>
</div>
)}
</div>
</div>
);
Expand Down
69 changes: 69 additions & 0 deletions src/app/(app)/admin/perks/export/route.ts
Original file line number Diff line number Diff line change
@@ -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",
},
});
}
71 changes: 71 additions & 0 deletions src/app/(public)/perks/helius/actions.ts
Original file line number Diff line number Diff line change
@@ -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<PerkError> {
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`);
}
50 changes: 50 additions & 0 deletions src/app/(public)/perks/helius/application.ts
Original file line number Diff line number Diff line change
@@ -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<Record<Exclude<PerkField, "server">, 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 },
};
}
30 changes: 30 additions & 0 deletions src/app/(public)/perks/helius/constants.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading