diff --git a/deep-sea-stories/packages/web/package.json b/deep-sea-stories/packages/web/package.json index 6141245..463afe7 100644 --- a/deep-sea-stories/packages/web/package.json +++ b/deep-sea-stories/packages/web/package.json @@ -35,6 +35,7 @@ "tailwindcss": "^4.1.13" }, "devDependencies": { + "@types/gtag.js": "^0.0.20", "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", "@vitejs/plugin-react": "^5.0.3", diff --git a/deep-sea-stories/packages/web/src/components/PromoWidget.tsx b/deep-sea-stories/packages/web/src/components/PromoWidget.tsx new file mode 100644 index 0000000..0289e0e --- /dev/null +++ b/deep-sea-stories/packages/web/src/components/PromoWidget.tsx @@ -0,0 +1,185 @@ +import { useEffect, useRef, useState } from 'react'; +import { Button } from './ui/button'; + +const PROMO_CODE = 'DeepSea25'; +const PROMO_URL = 'https://fishjam.swmansion.com/?utm_source=deep-sea-stories'; +const DISMISS_STORAGE_KEY = 'promo-widget-dismissed'; +const PROMO_HIDE_AFTER = new Date('2026-03-19T00:00:00Z'); + +const PROMO_DISMISSED_EVENT = 'promo_dismissed'; +const PROMO_CODE_DISPLAYED_EVENT = 'promo_code_displayed'; +const PROMO_CODE_COPIED_EVENT = 'promo_code_copied'; + +const readDismissedFromStorage = () => { + return window.localStorage.getItem(DISMISS_STORAGE_KEY) === 'true'; +}; + +const isExpired = Date.now() >= PROMO_HIDE_AFTER.getTime(); + +const sendGAEvent = (event: string) => { + if (typeof window.gtag !== 'function') { + console.warn('gtag not defined'); + return; + } + + try { + window.gtag('event', event, {}); + } catch (e) { + console.error(`Failed to send ${event} event`, e); + } +}; + +const PromoWidget = () => { + const [promoVisible, setPromoVisible] = useState(false); + const [dismissed, setDismissed] = useState(readDismissedFromStorage); + const [copied, setCopied] = useState(false); + + const copyResetRef = useRef(null); + + useEffect(() => { + return () => { + if (copyResetRef.current) { + window.clearTimeout(copyResetRef.current); + } + }; + }, []); + + useEffect(() => { + if (dismissed) { + window.localStorage.setItem(DISMISS_STORAGE_KEY, 'true'); + } + }, [dismissed]); + + const triggerCopiedFeedback = () => { + setCopied(true); + if (copyResetRef.current) { + window.clearTimeout(copyResetRef.current); + } + copyResetRef.current = window.setTimeout(() => setCopied(false), 2000); + }; + + const fallbackCopy = () => { + const textarea = document.createElement('textarea'); + textarea.value = PROMO_CODE; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + triggerCopiedFeedback(); + sendGAEvent(PROMO_CODE_COPIED_EVENT); + }; + + const handleGetPromo = () => { + sendGAEvent(PROMO_CODE_DISPLAYED_EVENT); + setPromoVisible(true); + }; + + const handleCopy = async () => { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(PROMO_CODE); + sendGAEvent(PROMO_CODE_COPIED_EVENT); + triggerCopiedFeedback(); + return; + } + } catch (error) { + console.warn('Failed to copy using clipboard API, falling back.', error); + } + + fallbackCopy(); + }; + + const handleDismiss = () => { + sendGAEvent(PROMO_DISMISSED_EVENT); + setDismissed(true); + }; + + if (dismissed || isExpired) { + return null; + } + + return ( +
+
+ + +
+
+

+ Powered by +

+ + Fishjam + +

+ The realtime infrastructure behind Deep Sea Stories. +
+ Build AI-first audio and video experiences without touching WebRTC + internals. +

+
+ +
+

+ Save 25% on your first three months of Regular Jar plan. +

+
+ + {promoVisible ? ( +
+
+ Promo code +
+
+ + {PROMO_CODE} + + +
+
+ ) : ( + + )} + + Redeem at fishjam.swmansion.com + +
+
+
+ ); +}; + +export default PromoWidget; diff --git a/deep-sea-stories/packages/web/src/main.tsx b/deep-sea-stories/packages/web/src/main.tsx index 1cebfbd..16f67d3 100644 --- a/deep-sea-stories/packages/web/src/main.tsx +++ b/deep-sea-stories/packages/web/src/main.tsx @@ -8,6 +8,7 @@ import { BrowserRouter, Route, Routes } from 'react-router'; import Layout from './Layout.tsx'; import HomeView from './views/HomeView.tsx'; import RoomView from './views/RoomView.tsx'; +import PromoWidget from './components/PromoWidget.tsx'; const queryClient = new QueryClient({ defaultOptions: { @@ -30,6 +31,9 @@ createRoot(document.getElementById('root')!).render( } /> +
+ +
diff --git a/deep-sea-stories/packages/web/src/types/window.d.ts b/deep-sea-stories/packages/web/src/types/window.d.ts new file mode 100644 index 0000000..786b646 --- /dev/null +++ b/deep-sea-stories/packages/web/src/types/window.d.ts @@ -0,0 +1,9 @@ +import type { Gtag } from 'gtag.js'; + +declare global { + interface Window { + gtag: Gtag.Gtag; + } +} + +export {}; diff --git a/deep-sea-stories/packages/web/src/views/GameView.tsx b/deep-sea-stories/packages/web/src/views/GameView.tsx index 9a1cf61..da3855e 100644 --- a/deep-sea-stories/packages/web/src/views/GameView.tsx +++ b/deep-sea-stories/packages/web/src/views/GameView.tsx @@ -53,6 +53,7 @@ const GameView: FC = ({ roomId }) => {
+