/* =========================================================================== referrals.mjs - referral/UTM hand-off on the homepage and play navigation. No credentials or raw fingerprints are stored. This key/shape is shared with traffic.mjs and client referral-attribution.ts; cross-origin navigation carries query values. =========================================================================== */ import { captureTraffic, appendTraffic } from "./traffic.mjs"; export const ATTRIBUTION_KEY = "varkand_traffic"; export const DEVICE_KEY = "varkand.referral-device.v1"; /* ================ mergeAttribution ================ */ export function mergeAttribution( search, previous = {} ) { const result = { ...previous }; const query = new URLSearchParams( search ); const ref = query.get( "ref" )?.toUpperCase(); if ( ref && /^[A-Z2-7]{8}$/.test( ref ) ) result.ref = ref; for ( const key of [ "utm_source", "utm_medium", "utm_campaign" ] ) { const value = query.get( key ); if ( value && value.length <= 128 ) result[key] = value; } return result; } /* ================ captureReferralTraffic Keep first-touch UTM, but an explicit referral link must not be swallowed by an earlier marketing cookie. Inviter immutability begins at account creation. ================ */ export function captureReferralTraffic() { const here = typeof location === "undefined" ? { search: "" } : location; const doc = typeof document === "undefined" ? { cookie: "" } : document; const values = captureTraffic( here, doc ); const ref = new URLSearchParams( here.search ).get( "ref" )?.toUpperCase(); if ( ref && /^[A-Z2-7]{8}$/.test( ref ) && values.ref !== ref ) { values.ref = ref; try { const raw = doc.cookie.split( ";" ).map( v => v.trim() ).find( v => v.startsWith( ATTRIBUTION_KEY + "=" ) ); const saved = raw ? JSON.parse( decodeURIComponent( raw.slice( ATTRIBUTION_KEY.length + 1 ) ) ) : null; const now = Date.now(), expires = saved?.expires ?? now + 30 * 86400000; doc.cookie = ATTRIBUTION_KEY + "=" + encodeURIComponent( JSON.stringify( { expires, values } ) ) + "; Max-Age=" + Math.max( 0, Math.floor( (expires - now) / 1000 ) ) + "; Path=/; SameSite=Lax; Secure"; } catch {} } return values; } /* ================ referralSignupMetadata ================ */ export function referralSignupMetadata() { const attribution = captureReferralTraffic(); let device = ""; try { device = localStorage.getItem( DEVICE_KEY ) ?? ""; if ( !/^[a-zA-Z0-9-]{16,128}$/.test( device ) ) { device = crypto.randomUUID(); localStorage.setItem( DEVICE_KEY, device ); } } catch { device = ""; } return { ref: typeof attribution.ref === "string" ? attribution.ref : "", device }; } /* ================ carryReferralLinks Only authored same-site play links receive hand-off metadata. ================ */ export function carryReferralLinks( root = document ) { if ( typeof location === "undefined" || typeof root.querySelectorAll !== "function" ) return; referralSignupMetadata(); const attribution = captureReferralTraffic(); for ( const anchor of root.querySelectorAll( "a[href]" ) ) { anchor.href = appendTraffic( anchor.href, location.href, attribution ); } } if ( typeof document !== "undefined" ) carryReferralLinks( document );