mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 08:53:11 +00:00
151 lines
4.9 KiB
TypeScript
151 lines
4.9 KiB
TypeScript
/**
|
|
* C213 — the anonymous content-event vocabulary and the public beacon.
|
|
*
|
|
* One mechanism for every content subject: `subjectType` says what the event
|
|
* happened to (`peca` today; `card` is the additive extension of S32) and
|
|
* `subjectId` is that subject's stable key. The event carries NOTHING about the
|
|
* visitor — no IP, no cookie, no user-agent, no session — and the beacon below
|
|
* is fire-and-forget by contract: it never throws, never blocks and never
|
|
* delays the public page, the download or the share (fail-soft).
|
|
*
|
|
* Pure and client-safe: the public route's zod schema and the client
|
|
* components import the same vocabulary, so a new event type is one edit.
|
|
*/
|
|
|
|
import type { CardModelId } from '@/lib/cardModels'
|
|
|
|
export const CONTENT_EVENT_ENDPOINT = '/api/content-events'
|
|
|
|
export const CONTENT_EVENT_TYPES = [
|
|
'abertura',
|
|
'download',
|
|
'compartilhar_whatsapp',
|
|
'compartilhar_link',
|
|
] as const
|
|
|
|
export type ContentEventType = (typeof CONTENT_EVENT_TYPES)[number]
|
|
|
|
/** Admin/REST labels only — the public surfaces own their own copy. */
|
|
export const contentEventTypeLabels: Record<ContentEventType, string> = {
|
|
abertura: 'Abertura',
|
|
download: 'Download',
|
|
compartilhar_whatsapp: 'Clique em compartilhar (WhatsApp)',
|
|
compartilhar_link: 'Clique em compartilhar (link)',
|
|
}
|
|
|
|
export const isContentEventType = (value: unknown): value is ContentEventType =>
|
|
typeof value === 'string' && (CONTENT_EVENT_TYPES as readonly string[]).includes(value)
|
|
|
|
export const CONTENT_EVENT_SUBJECT_TYPES = ['peca', 'card'] as const
|
|
|
|
export type ContentEventSubjectType = (typeof CONTENT_EVENT_SUBJECT_TYPES)[number]
|
|
|
|
export const contentEventSubjectTypeLabels: Record<ContentEventSubjectType, string> = {
|
|
peca: 'Peça',
|
|
card: 'Card',
|
|
}
|
|
|
|
export const isContentEventSubjectType = (value: unknown): value is ContentEventSubjectType =>
|
|
typeof value === 'string' && (CONTENT_EVENT_SUBJECT_TYPES as readonly string[]).includes(value)
|
|
|
|
/**
|
|
* One grouped row of the anonymous-event aggregate, as the SQL reader returns
|
|
* it and any subject-specific mapper consumes it. Generic on purpose: the same
|
|
* shape serves the piece counters today and the card counters of S32.
|
|
*/
|
|
export type ContentEventAggregateRow = {
|
|
subjectId: string
|
|
type: string
|
|
count: number
|
|
}
|
|
|
|
type ContentEventBeaconBody = {
|
|
type: ContentEventType
|
|
pieceSlug: string
|
|
}
|
|
|
|
type CardDownloadBeaconBody = {
|
|
type: 'download'
|
|
subjectType: 'card'
|
|
cardModelId: CardModelId
|
|
stateDeputySlug?: string
|
|
}
|
|
|
|
/**
|
|
* Sends one anonymous event body and forgets it. `sendBeacon` survives the
|
|
* navigation a click is about to trigger (the WhatsApp tab, the download);
|
|
* `fetch` with `keepalive` is the fallback for browsers without it. Every
|
|
* branch is swallowed: a lost count is acceptable, a broken action is not.
|
|
*/
|
|
const postContentEventBody = (body: string): void => {
|
|
try {
|
|
if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
|
|
const sent = navigator.sendBeacon(
|
|
CONTENT_EVENT_ENDPOINT,
|
|
new Blob([body], { type: 'application/json' }),
|
|
)
|
|
if (sent) return
|
|
}
|
|
} catch {
|
|
// Fall through to the fetch fallback.
|
|
}
|
|
|
|
try {
|
|
void fetch(CONTENT_EVENT_ENDPOINT, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body,
|
|
keepalive: true,
|
|
}).catch(() => undefined)
|
|
} catch {
|
|
// Fail-soft: the public action never depends on the count.
|
|
}
|
|
}
|
|
|
|
/** One anonymous event of a published piece (C213). */
|
|
export const sendContentPieceEvent = (type: ContentEventType, pieceSlug: string): void => {
|
|
const body: ContentEventBeaconBody = { type, pieceSlug }
|
|
postContentEventBody(JSON.stringify(body))
|
|
}
|
|
|
|
/**
|
|
* S32 — one anonymous download event of a personalized card: the model id (the
|
|
* public catalog key) and, on the models with a state-deputy picker, the slug
|
|
* of the chosen state deputy. Same transport and same fail-soft contract as the
|
|
* piece beacon: it never throws and never delays the download.
|
|
*/
|
|
export const sendCardDownloadEvent = (
|
|
modelId: CardModelId,
|
|
stateDeputySlug?: string | null,
|
|
): void => {
|
|
const body: CardDownloadBeaconBody = {
|
|
type: 'download',
|
|
subjectType: 'card',
|
|
cardModelId: modelId,
|
|
}
|
|
if (stateDeputySlug) body.stateDeputySlug = stateDeputySlug
|
|
|
|
postContentEventBody(JSON.stringify(body))
|
|
}
|
|
|
|
type CardOpeningBeaconBody = {
|
|
type: 'abertura'
|
|
subjectType: 'card'
|
|
cardModelId: CardModelId
|
|
}
|
|
|
|
/**
|
|
* S38 — one anonymous opening event of a card model: the visitor clicked the
|
|
* catalogue item and was routed to the studio with the model chosen. Same
|
|
* transport and same fail-soft contract as the piece beacon: it never throws
|
|
* and never delays the navigation.
|
|
*/
|
|
export const sendCardOpeningEvent = (modelId: CardModelId): void => {
|
|
const body: CardOpeningBeaconBody = {
|
|
type: 'abertura',
|
|
subjectType: 'card',
|
|
cardModelId: modelId,
|
|
}
|
|
|
|
postContentEventBody(JSON.stringify(body))
|
|
}
|