From a6715c62bc18e1bdcf49708cb7f90b3b85887640 Mon Sep 17 00:00:00 2001 From: Michael Mainguy Date: Wed, 4 Mar 2026 21:19:07 -0600 Subject: [PATCH] Replace @zestyxyz/babylonjs-sdk with local ES-module ZestyBanner The SDK referenced a global BABYLON namespace which broke ES module imports. Rewrote as three local modules (zestyBanner, zestyNetworking, zestyFormats) using proper named imports from @babylonjs/core, with safe camera access. Co-Authored-By: Claude Opus 4.6 --- src/ads/zestyBanner.ts | 77 +++++++++++++++++++++++++++++++++++++ src/ads/zestyFormats.ts | 20 ++++++++++ src/ads/zestyNetworking.ts | 78 ++++++++++++++++++++++++++++++++++++++ src/core/xrSetup.ts | 12 ++++++ 4 files changed, 187 insertions(+) create mode 100644 src/ads/zestyBanner.ts create mode 100644 src/ads/zestyFormats.ts create mode 100644 src/ads/zestyNetworking.ts diff --git a/src/ads/zestyBanner.ts b/src/ads/zestyBanner.ts new file mode 100644 index 0000000..2c6359c --- /dev/null +++ b/src/ads/zestyBanner.ts @@ -0,0 +1,77 @@ +import { + MeshBuilder, ActionManager, ExecuteCodeAction, + StandardMaterial, Texture, WebXRState, +} from '@babylonjs/core'; +import type { Scene, Mesh, WebXRDefaultExperience } from '@babylonjs/core'; +import { formats } from './zestyFormats'; +import { + fetchCampaignAd, sendOnLoadMetric, sendOnClickMetric, + AD_REFRESH_INTERVAL, +} from './zestyNetworking'; +import type { AdData } from './zestyNetworking'; + +function getCamera(scene: Scene, xr?: WebXRDefaultExperience) { + if (xr?.baseExperience?.state === WebXRState.IN_XR) { + return xr.baseExperience.camera; + } + return scene.cameras.length > 0 ? scene.cameras[0] : null; +} + +async function loadAd( + banner: Mesh, scene: Scene, adUnitId: string, + format: string, adRef: { current: AdData } +): Promise { + const ad = await fetchCampaignAd(adUnitId, format); + adRef.current = ad; + const mat = banner.material as StandardMaterial; + mat.diffuseTexture?.dispose(); + const tex = new Texture(ad.assetUrl, scene); + tex.hasAlpha = true; + mat.diffuseTexture = tex; + if (ad.campaignId) sendOnLoadMetric(adUnitId, ad.campaignId); +} + +function openUrl(url: string): void { + window.open(url, '_blank'); +} + +export function createZestyBanner( + adUnitId: string, format: string, height: number, + scene: Scene, xr?: WebXRDefaultExperience +): Mesh { + const fmt = formats[format] ?? formats['billboard']; + const planeOpts = { width: fmt.width * height, height }; + const banner = MeshBuilder.CreatePlane('zestybanner', planeOpts, scene); + + const mat = new StandardMaterial('zestyMat', scene); + mat.diffuseTexture = new Texture(fmt.defaultImage, scene); + (mat.diffuseTexture as Texture).hasAlpha = true; + banner.material = mat; + + const adRef = { current: { assetUrl: '', ctaUrl: '', campaignId: '' } }; + loadAd(banner, scene, adUnitId, format, adRef); + + banner.actionManager = new ActionManager(scene); + banner.actionManager.registerAction( + new ExecuteCodeAction(ActionManager.OnPickTrigger, () => { + if (!adRef.current.ctaUrl) return; + if (xr?.baseExperience) { + xr.baseExperience.sessionManager.exitXRAsync() + .then(() => openUrl(adRef.current.ctaUrl)); + } else { + openUrl(adRef.current.ctaUrl); + } + if (adRef.current.campaignId) { + sendOnClickMetric(adUnitId, adRef.current.campaignId); + } + }) + ); + + setInterval(() => { + const cam = getCamera(scene, xr); + if (!cam || !scene.isActiveMesh(banner)) return; + loadAd(banner, scene, adUnitId, format, adRef); + }, AD_REFRESH_INTERVAL); + + return banner; +} diff --git a/src/ads/zestyFormats.ts b/src/ads/zestyFormats.ts new file mode 100644 index 0000000..76a8842 --- /dev/null +++ b/src/ads/zestyFormats.ts @@ -0,0 +1,20 @@ +const CDN_BASE = 'https://cdn.zesty.xyz/sdk/assets'; + +export interface FormatInfo { + width: number; + height: number; + defaultImage: string; +} + +export const formats: Record = { + billboard: { + width: 3.88, + height: 1, + defaultImage: `${CDN_BASE}/zesty-default-billboard.png`, + }, + 'medium-rectangle': { + width: 1.2, + height: 1, + defaultImage: `${CDN_BASE}/zesty-default-medium-rectangle.png`, + }, +}; diff --git a/src/ads/zestyNetworking.ts b/src/ads/zestyNetworking.ts new file mode 100644 index 0000000..3ba7731 --- /dev/null +++ b/src/ads/zestyNetworking.ts @@ -0,0 +1,78 @@ +import { formats } from './zestyFormats'; + +const API_BASE = 'https://api.zesty.market/api'; +const GRAPHQL_URL = 'https://market.zesty.xyz/graphql'; + +export const AD_REFRESH_INTERVAL = 30000; + +export interface AdData { + assetUrl: string; + ctaUrl: string; + campaignId: string; +} + +function detectPlatform(): { name: string; confidence: string } { + const ua = navigator.userAgent; + if (/OculusBrowser/i.test(ua)) return { name: 'Oculus', confidence: 'high' }; + if (/Wolvic/i.test(ua)) return { name: 'Wolvic', confidence: 'high' }; + if (/Pico/i.test(ua)) return { name: 'Pico', confidence: 'medium' }; + return { name: 'Desktop', confidence: 'low' }; +} + +export async function fetchCampaignAd( + adUnitId: string, format: string +): Promise { + try { + const url = encodeURI(window.location.href).replace(/\/$/, ''); + const res = await fetch( + `${API_BASE}/ad?ad_unit_id=${adUnitId}&url=${url}` + ); + if (res.status === 200) { + const json = await res.json(); + if (json.Ads?.length) { + return { + assetUrl: json.Ads[0].asset_url, + ctaUrl: json.Ads[0].cta_url, + campaignId: json.CampaignId ?? '', + }; + } + } + } catch { + console.warn('Zesty: failed to fetch ad, using default'); + } + const fmt = formats[format] ?? formats['billboard']; + return { assetUrl: fmt.defaultImage, ctaUrl: '', campaignId: '' }; +} + +async function sendMetric( + eventType: string, adUnitId: string, campaignId: string +): Promise { + const { name, confidence } = detectPlatform(); + const query = `mutation { increment( + eventType: ${eventType}, + spaceId: "${adUnitId}", + campaignId: "${campaignId}", + platform: { name: ${name}, confidence: ${confidence} } + ) { message } }`; + try { + await fetch(GRAPHQL_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }), + }); + } catch { + console.warn(`Zesty: failed to send ${eventType} metric`); + } +} + +export async function sendOnLoadMetric( + adUnitId: string, campaignId: string +): Promise { + await sendMetric('visits', adUnitId, campaignId); +} + +export async function sendOnClickMetric( + adUnitId: string, campaignId: string +): Promise { + await sendMetric('clicks', adUnitId, campaignId); +} diff --git a/src/core/xrSetup.ts b/src/core/xrSetup.ts index db76b2b..9a74088 100644 --- a/src/core/xrSetup.ts +++ b/src/core/xrSetup.ts @@ -5,6 +5,7 @@ import { import { DefaultScene } from "./defaultScene"; import { InputControlManager } from "../ship/input/inputControlManager"; import log from './logger'; +import { createZestyBanner } from '../ads/zestyBanner'; const XR_RENDERING_GROUP = 3; const FADE_DELAY_MS = 500; @@ -54,7 +55,18 @@ async function createXRExperience(): Promise { disablePointerSelection: true // Disable to re-enable with custom options }); log.debug(WebXRFeaturesManager.GetAvailableFeatures()); + try { + const banner = createZestyBanner("a2170882-f232-4da0-9315-747ee049e642", + "billboard", 10, DefaultScene.MainScene, DefaultScene.XR); + banner.position.z = 50; + banner.position.y = 5; + banner.rotation.y = Math.PI; + banner.renderOverlay = true; + banner.overlayColor = Color3.Red(); + } catch (e) { + log.debug("Zesty banner init failed:", e); + } // Enable pointer selection with renderingGroupId so laser is never occluded DefaultScene.XR.baseExperience.featuresManager.enableFeature( WebXRFeatureName.POINTER_SELECTION,