Strategy — campanhas de aquisicao por UTM
Uma strategy e uma campanha disparada por UTM que abre um iframe externo (app Amplify) por cima do site. Cada brand declara a propria lista de strategies e o proprio registro de handlers de postMessage.
Master switch desligado no base: strategyConfig.enabled = false. Sem override, o overlay nunca monta e nenhuma strategy roda. Hoje so 7k-bet-br opt-in — consulte overrides/*/app/config/strategy/ pra estado corrente.
:::danger O trap do self-import — leia antes de escrever um override
O brandOverridesPlugin intercepta todo import ~/config/.... Pra uma brand que tem overrides/<brand>/app/config/strategy/strategy.ts, o import ~/config/strategy/strategy resolve pro proprio arquivo de override.
Ou seja: se o override importar defaultEventHandlers de ~/config/strategy/strategy, ele cai num self-import e o SSR quebra no boot com SyntaxError.
Por isso defaultEventHandlers vive em app/utils/strategy/default-event-handlers.ts, e nao em app/config/. Sempre importe dali:
// ✅ correto — em overrides/<brand>/app/config/strategy/strategy.ts
import { defaultEventHandlers } from "~/utils/strategy/default-event-handlers";
// ❌ SyntaxError no boot do SSR — self-import
import { defaultEventHandlers } from "~/config/strategy/strategy";
:::
Arquivos
| Arquivo | Papel |
|---|---|
app/config/strategy/strategy.ts | strategyConfig — default desligado |
app/types/strategy.ts | Todos os types do dominio |
app/store/strategy.ts | useStrategyStore — pipeline, dedup, iframe, dispatch de eventos |
app/utils/strategy/default-event-handlers.ts | defaultEventHandlers (fora de config/ de proposito) |
app/utils/strategy/utm.ts | extractUtmParams(), matchStrategies() |
app/utils/strategy/iframe.ts | buildIframeUrl() |
app/utils/strategy/event-data.ts | extractEventData(), safeIframeNavigate() |
app/utils/ftd-encrypted-id.ts | encodeEncryptedIdForFtd() |
app/components/strategy/StrategyOverlay.tsx | Montado (lazy) no DefaultLayout |
app/components/strategy/IframeOverlay.tsx | O overlay em si |
app/hooks/useStrategyAuthTrigger.ts | Dispatch imperativo pos-login/registro |
app/services/strategy-campaign-api.client.ts | createCampaignApiService() — Amigos/Connect |
app/services/campaign-feature-flag.client.ts | Servico de feature flag remoto |
app/widgets/campaign-widget/ | CampaignWidget / CampaignWidgetMulti — outra superficie do mesmo iframe |
:::info O contrato do iframe e externo e imutavel O shape do payload e os nomes de evento sao definidos pelos apps Amplify, que nao sao atualizados. Nada disso pode ser renomeado do lado do site. :::
Config — StrategyConfig
export interface StrategyConfig {
/** Master switch. Quando false, o overlay nunca monta. */
enabled: boolean;
/** Identificador da brand anexado a URL do iframe (`?brand=…`). */
brand: string;
/** Config da Campaign API (null = pula checagens de minigame/campanha). */
campaignApi: CampaignApiConfig | null;
/** Config do servico de feature flag remoto (null = sem gate remoto). */
featureFlagApi: CampaignFeatureFlagApiConfig | null;
/** Strategies registradas pra esta brand. */
strategies: StrategyDefinition[];
/** Handlers keyed pelo nome do evento vindo do postMessage do iframe. */
eventHandlers: Record<string, StrategyEventHandler>;
}
O default no base:
export const strategyConfig: StrategyConfig = {
enabled: false,
brand: "",
campaignApi: null,
featureFlagApi: null,
strategies: [],
eventHandlers: defaultEventHandlers,
};
StrategyDefinition
| Campo | Descricao |
|---|---|
name | Nome unico legivel — usado em log e no dedup de sessionStorage |
utmPatterns | Substrings casadas contra utm_oferta e/ou utm_campaign |
iframeBaseUrl? | URL base do iframe. Opcional so quando headless: true |
trigger? | "register" (default), "login" ou "visit" |
repeat? | true reabre a cada visita que casa; false (default) abre uma vez por sessao |
campaignId? | Registra o usuario na campanha Amigos/Connect antes de abrir o iframe |
minigameId? | Guard de replay — pula quando o usuario ja jogou |
featureFlagId? | Gate remoto (statusWhiteLabel) |
markMinigamePlayed? | { bonusEarned } — credita o minigame como jogado (requer minigameId) |
headless? | Nao abre iframe; roda apenas os passos da Connect |
extraParams? | Query params extras na URL do iframe (ex.: { id: "0001" }) |
encodeFtdInEncryptedId? | Codifica o encrypted_user_id com marcador @, so pra usuario com FTD |
includeUserFullName? | Anexa userFullName a querystring — so quando o caller passa um |
suppressSmarticoOverlays? | z-index maximo + rebaixa popups do Smartico enquanto o iframe esta aberto |
includeUserFullName e PII
Opt-in por strategy, deliberadamente: (a) e PII na querystring, cacheada por CDN e logs do Amplify; (b) a maioria das strategies nao precisa. Responsabilidade do caller: gatear por isAuthenticated antes de passar o nome — anonimo passa undefined e o param e omitido.
suppressSmarticoOverlays
Opt-in porque tem efeito colateral. Quando true, o IframeOverlay usa z-index maximo e injeta CSS que rebaixa popups do CRM Smartico (achievements, notifications, mission completion) enquanto o iframe esta aberto. Sem isso, um popup do Smartico pode disparar em cima do iframe e quebrar a experiencia — o que importa em strategies onde o usuario passa minutos dentro do iframe.
Matching de UTM
extractUtmParams() le apenas utm_oferta e utm_campaign.
:::note utm_source / utm_medium / utm_content sao ignorados
Paridade com o legado Vue. O matcher nao os considera.
:::
matchStrategies() faz substring match: uma strategy casa quando qualquer um dos seus utmPatterns e substring de utm_oferta ou de utm_campaign. Patterns vazios sao ignorados; sem nenhum dos dois UTMs, o resultado e lista vazia.
Triggers e quem despacha
trigger | Quem dispara | Como |
|---|---|---|
"visit" | StrategyOverlay | Reativo — useEffect sobre searchParams, depois de authHydrated |
"register" | RegisterModal | Imperativo, via useStrategyAuthTrigger() |
"login" | LoginModal | Imperativo, via useStrategyAuthTrigger() |
:::warning Por que auth-triggered e imperativo
Se o StrategyOverlay avaliasse strategies de register/login reativamente, um usuario ja autenticado que aterrissasse com a UTM dispararia a campanha de cadastro. Era exatamente o bug do legado que essa migracao corrige. Por isso quem chama e o modal, no .then() do login/registro bem-sucedido.
:::
import { useStrategyAuthTrigger } from "~/hooks/useStrategyAuthTrigger";
const triggerAuthStrategy = useStrategyAuthTrigger();
// ...
if (response.user) {
trackLogin(response.user);
await triggerAuthStrategy("login", response.user);
}
O hook resolve userId, encryptedUserId e hasFtd do AuthUser e devolve Promise<boolean> indicando se um iframe foi realmente aberto.
O pipeline
Pra cada strategy que casou, em ordem (app/store/strategy.ts):
| # | Passo | Se falhar |
|---|---|---|
| 0 | Dedup — !repeat && shown.has(name) → pula | — |
| 1 | Feature flag remoto — featureFlagApi.getById(featureFlagId); pula se nao habilitado | Sem featureFlagApi configurado: gate pulado + warning em dev |
| 2 | Validacao de config — headless exige minigameId + markMinigamePlayed; nao-headless exige iframeBaseUrl | Pula + warning em dev |
| 3 | setup(userId) — cria o usuario na Amigos/Connect se faltar | null → pula a strategy |
| 4 | hasPlayedMinigame(user, minigameId) — checagem local pura, sem fetch extra | true → pula |
| 5 | assignToCampaign(user, userId, campaignId) — atribuicao de BI | Falha → abre o iframe de qualquer forma (com warning). Sem campaignId → warning em dev, BI perde o usuario |
| 6 | addPlayedMinigame(...) — padrao legado de credito headless | Fire-and-forget |
| 7 | Abre o iframe (ou retorna, se headless) e marca como visto | — |
A primeira strategy que chega ao passo 7 encerra o loop (return true).
O user devolvido pelo setup e threaded pelo resto do pipeline, de proposito: evita re-fetch pra checagem de minigame e pro assign. E o comportamento do legado, que cacheava o user em memoria depois do getById/create.
O passo 3 e o 5 sao pulados quando userId nao e real (0, "0", vazio) — usuario anonimo nao tem nada a registrar na Connect.
Dedup
sessionStorage["strategy_shown"] guarda um array JSON com os name das strategies ja abertas na sessao. Escrita e best-effort: sessionStorage pode lancar em modo privado ou por quota, e o codigo trata.
repeat: true ignora o dedup.
Campaign API (Amigos/Connect)
createCampaignApiService({ baseUrl }) (app/services/strategy-campaign-api.client.ts) implementa CampaignApiService:
| Metodo | Contrato |
|---|---|
setup(userId) | Garante o usuario na API; cria se faltar. Devolve o CampaignApiUser (existente ou recem-criado) ou null em falha |
hasPlayedMinigame(user, minigameId) | Checagem pura sobre user.minigames_played. Sem chamada de rede |
assignToCampaign(user, userId, campaignId) | PUT /user/activate-campaign/:userId. true se o usuario ja esta na campanha ou o PUT deu certo |
addPlayedMinigame(userId, minigameId, bonusEarned) | Marca o minigame como jogado. O backend enfileira um job e sempre devolve 200 com mensagem generica — o retorno so sinaliza sucesso/falha de rede. O caller precisa evitar chamada duplicada (use hasPlayedMinigame antes) |
:::note O front e responsavel pelo assign
Confirmado com o time de backend: strategies de trafego sao registradas na Connect pelo front, nao pelo iframe do Amplify. Sem campaignId a atribuicao de BI nao acontece.
:::
Feature flag remoto
CampaignFeatureFlagService.getById(id) busca a flag e cacheia em memoria pelo tempo de vida da instancia; isEnabled(flag) le statusWhiteLabel. statusMirror existe no type mas e informativo — a implementacao atual nao gateia nele.
Strategies (e configs de CampaignWidget) que declaram featureFlagId exigem featureFlagApi configurado no strategyConfig; sem ele o gate e pulado com warning em dev.
:::info Nao confunda com o feature-flag do FTD Este e um servico diferente do usado pelos fluxos FTD (ver FTD Suite). Sao dois providers legados distintos, com contratos distintos. :::
Construcao da URL do iframe
buildIframeUrl() (app/utils/strategy/iframe.ts) precisa lidar com iframeBaseUrl que ja vem com query params — padrao legado, ex. ".../?encryptedId=2&userId=2&id=0003". As regras:
userId/encryptedIddo usuario sao escritos so quando sao reais (nao-zero, nao-vazio). Se o usuario e anonimo e a base URL ja tem ids placeholder, eles sao preservados — contrato legado do qual alguns apps Amplify dependem. Fora desse caso, anonimo recebe0nos dois.brande escrito so quando ausente na base URL.extraParamsdaStrategyDefinitionsao escritos por ultimo e sempre sobrescrevem — e config explicita da brand.encodeFtdInEncryptedId+hasFtd→ oencrypted_user_idpassa porencodeEncryptedIdForFtd().userFullNameso entra quando nao-vazio.
Chamar buildIframeUrl numa strategy sem iframeBaseUrl lanca — strategies headless nao devem ser abertas num iframe.
Handlers de postMessage
StrategyEventHandler recebe (context, event):
export interface StrategyEventContext {
closeIframe: () => void;
navigate: (path: string) => void;
openPayment?: (type: "deposit" | "withdraw") => void;
openAuth?: (mode: "login" | "register") => void;
shareReferral?: () => void;
/** Responde ao iframe aberto (ex.: `get-sportbook-authentication`). */
sendMessage?: (eventName: string, payload: unknown) => void;
}
O evento cru varia entre campanhas legadas — algumas emitem payload, outras message, algumas as duas:
export interface StrategyIframeEvent {
event: string;
payload?: Record<string, unknown>;
message?: Record<string, unknown>;
}
:::warning Normalize antes de ler campos
Use extractEventData(event) de ~/utils/strategy/event-data. Ler event.payload direto quebra nas campanhas que so emitem message.
Pra navegar com um path vindo do iframe, use safeIframeNavigate(navigate, url) — nunca passe o valor cru pro navigate.
:::
defaultEventHandlers
| Evento | Comportamento default |
|---|---|
cancel | fecha o iframe |
close | fecha o iframe |
close-to-home | fecha e navega pra / |
redirect | fecha e navega pro path do payload (via safeIframeNavigate, fallback /) |
redirect-to-deposit | fecha e abre o modal de deposito |
redirect-to-login | apenas fecha |
redirect-to-register | apenas fecha |
Os dois ultimos so fecham de proposito: brands que querem abrir o modal de auth sobrescrevem o handler.
Escrevendo um override
// overrides/<brand>/app/config/strategy/strategy.ts
import type { StrategyConfig, StrategyDefinition } from "~/types/strategy";
import { defaultEventHandlers } from "~/utils/strategy/default-event-handlers";
const strategies: StrategyDefinition[] = [
{
name: "High Tickets",
utmPatterns: ["novo-high-ticket"],
campaignId: "…",
minigameId: "…",
iframeBaseUrl: "https://….amplifyapp.com/",
trigger: "register",
},
];
export const strategyConfig: StrategyConfig = {
enabled: true,
brand: "…",
campaignApi: { baseUrl: "https://…" },
featureFlagApi: { baseUrl: "https://…" },
strategies,
eventHandlers: {
...defaultEventHandlers,
"redirect-to-login": ({ closeIframe, openAuth }) => {
closeIframe();
openAuth?.("login");
},
},
};
Lembretes:
- Import de
defaultEventHandlerssempre de~/utils/strategy/default-event-handlers. - Override e substituicao de arquivo inteiro. Ao adicionar um campo ao
StrategyConfig, propague pra todos os overrides — ver Override Files.
eventHandlers e compartilhado com o CampaignWidget
O mesmo registro de handlers serve StrategyOverlay e CampaignWidget em modo iframe (app/widgets/campaign-widget/). As duas superficies encaminham os eventos de postMessage pelo mesmo registro da brand, entao o app do iframe nao precisa saber qual superficie o abriu.
Store — API publica
| Metodo | Uso |
|---|---|
openStrategiesForVisit(params) | Chamado pelo StrategyOverlay quando ha UTM |
openStrategiesForAuth(params) | Chamado pelo useStrategyAuthTrigger |
openStrategyByName(name, params?) | Abre uma strategy especifica (ex.: CTA de sidebar) |
handleIframeMessage(event, context) | Roteia o evento pro handler da brand |
sendMessage(eventName, payload) | postMessage de volta pro iframe aberto |
setIframeRef(ref) | Registra o ref do <iframe> |
closeIframe() | Limpa iframeSrc, showIframe, currentStrategy |
devForceAuthStrategy(params) | Atalho de dev |
Estado: iframeSrc, showIframe, currentStrategy, iframeRef, lastVisitEvaluatedKey (memo que evita re-avaliar a mesma combinacao de visita).
Auth vem de useAccountsStore, nunca de loaderData — ver State Management.