Pular para o conteúdo principal

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

ArquivoPapel
app/config/strategy/strategy.tsstrategyConfig — default desligado
app/types/strategy.tsTodos os types do dominio
app/store/strategy.tsuseStrategyStore — pipeline, dedup, iframe, dispatch de eventos
app/utils/strategy/default-event-handlers.tsdefaultEventHandlers (fora de config/ de proposito)
app/utils/strategy/utm.tsextractUtmParams(), matchStrategies()
app/utils/strategy/iframe.tsbuildIframeUrl()
app/utils/strategy/event-data.tsextractEventData(), safeIframeNavigate()
app/utils/ftd-encrypted-id.tsencodeEncryptedIdForFtd()
app/components/strategy/StrategyOverlay.tsxMontado (lazy) no DefaultLayout
app/components/strategy/IframeOverlay.tsxO overlay em si
app/hooks/useStrategyAuthTrigger.tsDispatch imperativo pos-login/registro
app/services/strategy-campaign-api.client.tscreateCampaignApiService() — Amigos/Connect
app/services/campaign-feature-flag.client.tsServico 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

CampoDescricao
nameNome unico legivel — usado em log e no dedup de sessionStorage
utmPatternsSubstrings 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

triggerQuem disparaComo
"visit"StrategyOverlayReativo — useEffect sobre searchParams, depois de authHydrated
"register"RegisterModalImperativo, via useStrategyAuthTrigger()
"login"LoginModalImperativo, 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):

#PassoSe falhar
0Dedup!repeat && shown.has(name) → pula
1Feature flag remotofeatureFlagApi.getById(featureFlagId); pula se nao habilitadoSem featureFlagApi configurado: gate pulado + warning em dev
2Validacao de configheadless exige minigameId + markMinigamePlayed; nao-headless exige iframeBaseUrlPula + warning em dev
3setup(userId) — cria o usuario na Amigos/Connect se faltarnull → pula a strategy
4hasPlayedMinigame(user, minigameId) — checagem local pura, sem fetch extratrue → pula
5assignToCampaign(user, userId, campaignId) — atribuicao de BIFalha → abre o iframe de qualquer forma (com warning). Sem campaignId → warning em dev, BI perde o usuario
6addPlayedMinigame(...) — padrao legado de credito headlessFire-and-forget
7Abre 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:

MetodoContrato
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 / encryptedId do 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 recebe 0 nos dois.
  • brand e escrito so quando ausente na base URL.
  • extraParams da StrategyDefinition sao escritos por ultimo e sempre sobrescrevem — e config explicita da brand.
  • encodeFtdInEncryptedId + hasFtd → o encrypted_user_id passa por encodeEncryptedIdForFtd().
  • userFullName so 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

EventoComportamento default
cancelfecha o iframe
closefecha o iframe
close-to-homefecha e navega pra /
redirectfecha e navega pro path do payload (via safeIframeNavigate, fallback /)
redirect-to-depositfecha e abre o modal de deposito
redirect-to-loginapenas fecha
redirect-to-registerapenas 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 defaultEventHandlers sempre 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

MetodoUso
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.