From a89b448cc8c19c2048dad45095013fc777a29f87 Mon Sep 17 00:00:00 2001 From: ML Date: Sun, 24 Nov 2024 23:09:44 +0100 Subject: [PATCH] tags and revenue tracking --- playground/nuxt.config.ts | 1 + src/module.ts | 2 ++ src/runtime/composables.ts | 53 +++++++++++++++++++++++++++++++++++--- src/runtime/logger.ts | 9 ++++--- src/runtime/utils.ts | 39 ++++++++++++++++++++++------ src/types.ts | 43 +++++++++++++++++++++---------- 6 files changed, 119 insertions(+), 28 deletions(-) diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index b3a71cb..260b52e 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -17,6 +17,7 @@ export default defineNuxtConfig({ excludeQueryParams: false, trailingSlash: 'always', proxy: 'cloak', + tag: 'gondor', }, appConfig: { diff --git a/src/module.ts b/src/module.ts index b3da4b1..3cb4552 100644 --- a/src/module.ts +++ b/src/module.ts @@ -38,6 +38,7 @@ export default defineNuxtModule({ const envHost = ENV.NUXT_UMAMI_HOST || ENV.NUXT_PUBLIC_UMAMI_HOST; const envId = ENV.NUXT_UMAMI_ID || ENV.NUXT_PUBLIC_UMAMI_ID; + const envTag = ENV.NUXT_UMAMI_TAG || ENV.NUXT_PUBLIC_UMAMI_TAG; const { enabled, @@ -52,6 +53,7 @@ export default defineNuxtModule({ ...options, ...(isValidString(envId) && { id: envId }), ...(isValidString(envHost) && { host: envHost }), + ...(isValidString(envTag) && { tag: envTag }), }); const endpoint = host ? new URL(host).origin + (customEndpoint || '/api/send') : ''; diff --git a/src/runtime/composables.ts b/src/runtime/composables.ts index de925dd..59f3c38 100644 --- a/src/runtime/composables.ts +++ b/src/runtime/composables.ts @@ -1,9 +1,15 @@ import type { - StaticPayload, EventPayload, ViewPayload, IdentifyPayload, - PreflightResult, EventData, FetchResult, + CurrencyCode, + EventData, + EventPayload, + FetchResult, + IdentifyPayload, + PreflightResult, + StaticPayload, + ViewPayload, } from '../types'; -import { earlyPromise, flattenObject, isValidString } from './utils'; import { buildPathUrl, collect, config, logger } from '#build/umami.config.mjs'; +import { earlyPromise, flattenObject, isValidString } from './utils'; let configChecks: PreflightResult | undefined; let staticPayload: StaticPayload | undefined; @@ -46,10 +52,13 @@ function getStaticPayload(): StaticPayload { navigator: { language }, } = window; + const { tag } = config; + staticPayload = { hostname, language, screen: `${width}x${height}`, + ...(tag ? { tag } : null), }; return staticPayload; @@ -175,4 +184,40 @@ function umIdentify(sessionData?: EventData): FetchResult { }); } -export { umTrackEvent, umTrackView, umIdentify }; +/** + * Tracks financial performance + * @see [Umami Docs](https://umami.is/docs/reports/report-revenue) + * + * @param eventName [revenue] event name + * @param revenue revenue / amount + * @param currency currency code (defaults to USD) + * ([ISO 4217](https://en.wikipedia.org/wiki/ISO_4217#List_of_ISO_4217_currency_codes)) + */ +function umTrackRevenue( + eventName: string, + revenue: number, + currency: CurrencyCode = 'USD', +): FetchResult { + const $rev = typeof revenue === 'number' ? revenue : Number(revenue); + + if (Number.isNaN($rev) || !Number.isFinite(revenue)) { + // if you ever run into troubles with isFinite (or not), + // please buy me a coffee ;) bmc.link/ijkml + logger('revenue', revenue); + return earlyPromise(false); + } + + let $cur: string | null = null; + + if (typeof currency === 'string' && /^[A-Z]{3}$/i.test(currency.trim())) + $cur = currency.trim(); + else + logger('currency', `Got: ${currency}`); + + return umTrackEvent(eventName, { + revenue: $rev, + ...($cur ? { currency: $cur } : null), + }); +} + +export { umIdentify, umTrackEvent, umTrackRevenue, umTrackView }; diff --git a/src/runtime/logger.ts b/src/runtime/logger.ts index 487d0b3..6064b1c 100644 --- a/src/runtime/logger.ts +++ b/src/runtime/logger.ts @@ -1,7 +1,8 @@ import type { PreflightResult } from '../types'; type PreflightErrId = Exclude - | 'collect' | 'directive' | 'event-name' | 'endpoint' | 'id' | 'enabled'; + | 'collect' | 'directive' | 'event-name' | 'endpoint' | 'id' + | 'enabled' | 'currency' | 'revenue'; type LogLevel = 'info' | 'warn' | 'error'; interface ErrorObj { level: LogLevel; @@ -16,8 +17,10 @@ const warnings: Record = { 'localhost': { level: 'info', text: 'Tracking disabled on localhost' }, 'local-storage': { level: 'info', text: 'Tracking disabled via local-storage' }, 'collect': { level: 'error', text: 'Uhm... Something went wrong and I have no clue.' }, - 'directive': { level: 'error', text: 'Invalid v-umami directive value. Expected string or object with {key:value} pairs. See https://github.com/ijkml/nuxt-umami#available-methods' }, + 'directive': { level: 'error', text: 'Invalid v-umami directive value. Expected string or object with {key:value} pairs. See https://umami.nuxt.dev/api/usage#directive' }, 'event-name': { level: 'warn', text: 'An Umami track event was fired without a name. `#unknown-event` will be used as event name.' }, + 'currency': { level: 'warn', text: 'Invalid currency passed. Expected ISO 4217 format. See https://en.wikipedia.org/wiki/ISO_4217#List_of_ISO_4217_currency_codes' }, + 'revenue': { level: 'error', text: 'Revenue is not a number. Expected number, got: ' }, }; function logger(id: PreflightErrId, raw?: unknown) { @@ -30,4 +33,4 @@ function logger(id: PreflightErrId, raw?: unknown) { function fauxLogger(..._args: Parameters) {} -export { logger, fauxLogger }; +export { fauxLogger, logger }; diff --git a/src/runtime/utils.ts b/src/runtime/utils.ts index 9a32921..ade1f9e 100644 --- a/src/runtime/utils.ts +++ b/src/runtime/utils.ts @@ -1,6 +1,11 @@ import type { - EventPayload, PayloadTypes, ServerPayload, ViewPayload, - FetchResult, ModuleOptions, NormalizedModuleOptions, + EventPayload, + FetchResult, + ModuleOptions, + NormalizedModuleOptions, + PayloadTypes, + ServerPayload, + ViewPayload, } from '../types'; function earlyPromise(ok: boolean): FetchResult { @@ -35,6 +40,7 @@ function normalizeConfig(options: ModuleOptions = {}): NormalizedModuleOptions { logErrors = false, enabled = true, trailingSlash = 'any', + tag = undefined, } = options; return { @@ -62,10 +68,12 @@ function normalizeConfig(options: ModuleOptions = {}): NormalizedModuleOptions { if ( isValidString(trailingSlash) && ['always', 'never'].includes(trailingSlash.trim()) - ) + ) { return trailingSlash.trim() as typeof trailingSlash; + } return 'any'; })(), + tag: isValidString(tag) ? tag.trim() : null, ignoreLocalhost: ignoreLocalhost === true, autoTrack: autoTrack !== false, useDirective: useDirective === true, @@ -117,6 +125,7 @@ const _payloadProps: Record = { url: 'nonempty', referrer: 'string', title: 'string', + tag: 'skip', // optional property name: 'skip', // optional, 'nonempty' in EventPayload data: 'skip', // optional, 'data' in EventPayload & IdentifyPayload } as const; @@ -133,8 +142,12 @@ function isValidPayload(obj: object): obj is Payload { const validators: typeof _payloadProps = { ..._payloadProps }; const validatorKeys: Array = [ - 'hostname', 'language', 'screen', - 'url', 'referrer', 'title', + 'hostname', + 'language', + 'screen', + 'url', + 'referrer', + 'title', ]; if (objKeys.includes('name')) { @@ -149,11 +162,19 @@ function isValidPayload(obj: object): obj is Payload { validators.data = 'data'; } + // optional property is present, update validators + if (objKeys.includes('tag')) { + validatorKeys.push('tag'); + validators.tag = 'string'; + } + // check: all keys are present, no more, no less if ( objKeys.length !== validatorKeys.length || !validatorKeys.every(k => objKeys.includes(k)) - ) return false; + ) { + return false; + } // run each value against its validator for (const key in obj) { @@ -185,7 +206,9 @@ function parseEventBody(body: unknown): ValidatePayloadReturn { 'type' in body && isValidString(body.type) && 'cache' in body && typeof body.cache === 'string' && 'payload' in body && isRecord(body.payload) - )) return error; + )) { + return error; + } const { payload, cache, type } = body; @@ -204,8 +227,8 @@ function parseEventBody(body: unknown): ValidatePayloadReturn { export { earlyPromise, - isValidString, flattenObject, + isValidString, normalizeConfig, parseEventBody, }; diff --git a/src/types.ts b/src/types.ts index 4e9ff0f..87cc3fd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,6 +11,7 @@ type ModuleOptions = Partial<{ * * @required true * @example 'https://ijkml.xyz/' + * @see [How to find?](https://umami.nuxt.dev/api/configuration#finding-config-options). */ host: string; /** @@ -46,9 +47,15 @@ type ModuleOptions = Partial<{ * Self-hosted Umami lets you set a COLLECT_API_ENDPOINT, which is: * - `/api/collect` by default in Umami v1 * - `/api/send` by default in Umami v2. - * See Umami [Docs](https://umami.is/docs/environment-variables). + * See [Umami Docs](https://umami.is/docs/environment-variables). */ customEndpoint: string | null; + /** + * Use Umami tags for A/B testing or to group events. + * + * See [Umami Docs](https://umami.is/docs/tags). + */ + tag: string | null; /** * Exclude query/search params from tracked urls * @@ -72,7 +79,9 @@ type ModuleOptions = Partial<{ */ logErrors: boolean; /** - * API proxy mode (see docs) + * API proxy mode + * + * @see [Documentation](https://umami.nuxt.dev/api/configuration#proxy-mode). * * @default false */ @@ -116,6 +125,7 @@ interface StaticPayload { screen: string; language: string; hostname: string; + tag?: string; } interface ViewPayload extends StaticPayload { @@ -143,21 +153,28 @@ type FetchResult = Promise<{ ok: boolean }>; type FetchFn = (load: ServerPayload) => FetchResult; type BuildPathUrlFn = () => string; +type _Letter = `${'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H' +| 'I' | 'J' | 'K' | 'M' | 'L' | 'N' | 'O' | 'P' | 'Q' | 'R' | 'S' +| 'T' | 'U' | 'V' | 'W' | 'X' | 'Y' | 'Z'}`; + +type CurrencyCode = Uppercase<`${_Letter}${_Letter}${_Letter}`>; + export type { - PreflightResult, + BuildPathUrlFn, + CurrencyCode, + EventData, + EventPayload, + FetchFn, FetchResult, + IdentifyPayload, + ModuleMode, ModuleOptions, - EventData, - StaticPayload, NormalizedModuleOptions, - UmPublicConfig, - UmPrivateConfig, - ModuleMode, - FetchFn, - BuildPathUrlFn, PayloadTypes, - ViewPayload, - EventPayload, - IdentifyPayload, + PreflightResult, ServerPayload, + StaticPayload, + UmPrivateConfig, + UmPublicConfig, + ViewPayload, };