Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions deep-sea-stories/packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
185 changes: 185 additions & 0 deletions deep-sea-stories/packages/web/src/components/PromoWidget.tsx
Original file line number Diff line number Diff line change
@@ -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<number | null>(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 (
<div className="pointer-events-auto text-primary">
<div className="relative w-[min(22rem,_calc(100vw-2rem))] rounded-4xl shadow-amber-100/15 border border-border/80 bg-background/90 p-6 shadow-xl backdrop-blur-2xl">
<button
type="button"
aria-label="Dismiss promo"
onClick={handleDismiss}
className="absolute right-3 top-4 rounded-full bg-background/70 px-2 py-1 text-[0.55rem] font-semibold uppercase tracking-[0.2em] text-primary/60 transition hover:text-primary"
>
I'm not interested
</button>

<div className="flex flex-col gap-5 text-sm text-primary/80">
<div>
<p className="font-display text-[0.55rem] uppercase tracking-[0.6em] text-primary/60">
Powered by
</p>
<a
className="font-display text-2xl text-primary underline"
target="_blank"
rel="noopener"
href={PROMO_URL}
>
Fishjam
</a>
<p className="mt-2">
The realtime infrastructure behind Deep Sea Stories.
<br />
Build AI-first audio and video experiences without touching WebRTC
internals.
</p>
</div>

<div>
<p className="mt-1">
Save 25% on your first three months of Regular Jar plan.
</p>
</div>

{promoVisible ? (
<div className="rounded-3xl border border-border/60 bg-background/80 p-4">
<div className="flex items-center justify-between text-[0.65rem] font-display uppercase tracking-[0.4em] text-primary/60">
<span>Promo code</span>
</div>
<div className="mt-3 flex flex-wrap justify-between items-center gap-3">
<span className="font-mono text-lg tracking-[0.4em]">
{PROMO_CODE}
</span>
<Button
type="button"
variant="outline"
className="h-10 px-4 text-[0.6rem] font-semibold uppercase tracking-[0.3em]"
onClick={handleCopy}
>
{copied ? 'Copied' : 'Copy'}
</Button>
</div>
</div>
) : (
<Button
type="button"
onClick={handleGetPromo}
className="h-11 w-full text-sm font-display"
>
Get a promo code
</Button>
)}
<a
href={PROMO_URL}
target="_blank"
rel="noopener"
className="mt-1 text-center text-[0.75rem] font-semibold tracking-[0.1em] text-primary/70 underline-offset-4 transition-colors hover:text-primary hover:underline"
>
Redeem at fishjam.swmansion.com
</a>
</div>
</div>
</div>
);
};

export default PromoWidget;
4 changes: 4 additions & 0 deletions deep-sea-stories/packages/web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -30,6 +31,9 @@ createRoot(document.getElementById('root')!).render(
<Route path=":roomId" element={<RoomView />} />
</Routes>
</BrowserRouter>
<div className="absolute bottom-2 right-2 md:bottom-40 md:left-6 md:right-auto z-10">
<PromoWidget />
</div>
</Layout>
</FishjamProvider>
</TRPCClientProvider>
Expand Down
9 changes: 9 additions & 0 deletions deep-sea-stories/packages/web/src/types/window.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { Gtag } from 'gtag.js';

declare global {
interface Window {
gtag: Gtag.Gtag;
}
}

export {};
1 change: 1 addition & 0 deletions deep-sea-stories/packages/web/src/views/GameView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const GameView: FC<GameViewProps> = ({ roomId }) => {
<div className="absolute right-2 top-2 md:right-6 md:top-6 z-10">
<PlayerCountIndicator count={playerCount} />
</div>

<PeerGrid
roomId={roomId}
localPeer={localPeer}
Expand Down
8 changes: 8 additions & 0 deletions deep-sea-stories/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ __metadata:
"@trpc/client": "npm:^11.6.0"
"@trpc/server": "npm:^11.6.0"
"@trpc/tanstack-react-query": "npm:^11.6.0"
"@types/gtag.js": "npm:^0.0.20"
"@types/react": "npm:^19.1.13"
"@types/react-dom": "npm:^19.1.9"
"@vitejs/plugin-react": "npm:^5.0.3"
Expand Down Expand Up @@ -1824,6 +1825,13 @@ __metadata:
languageName: node
linkType: hard

"@types/gtag.js@npm:^0.0.20":
version: 0.0.20
resolution: "@types/gtag.js@npm:0.0.20"
checksum: 10c0/eb878aa3cfab6b98f5e69ef3383e9788aaea6a4d0611c72078678374dcbb4731f533ff2bf479a865536f1626a57887b1198279ff35a65d223fe4f93d9c76dbdd
languageName: node
linkType: hard

"@types/node@npm:*, @types/node@npm:^24.5.2":
version: 24.8.1
resolution: "@types/node@npm:24.8.1"
Expand Down