+
}
+ preview={
+
+ }
/>
+
+ );
+}
+
+function Preview({ adUrl, gameUrl }: { adUrl: string; gameUrl: string }) {
+ return (
+
+

+
);
}
diff --git a/client/src/pages/products/Section.tsx b/client/src/pages/products/Section.tsx
deleted file mode 100644
index f8d7891..0000000
--- a/client/src/pages/products/Section.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { Button } from "../../design/button/Button";
-import { Heading1 } from "../../design/heading/Heading";
-import { TextBlock } from "../../design/text-block/TextBlock";
-
-import styles from "./Section.module.css";
-
-export interface SectionProps {
- iconUrl: string;
- title: string;
- description: string;
- experienceUrl: string;
- previewAdUrl: string;
- previewGameUrl: string;
-}
-
-export function Section(props: SectionProps) {
- const { iconUrl, title, description, previewAdUrl, previewGameUrl } = props;
- return (
-
-
-

-
{title}
-
{description}
-
-
-
-
-
-

-

-
-
- );
-}
diff --git a/client/src/pages/products/ad-action/AdAction.module.css b/client/src/pages/products/ad-action/AdAction.module.css
new file mode 100644
index 0000000..23b4d6a
--- /dev/null
+++ b/client/src/pages/products/ad-action/AdAction.module.css
@@ -0,0 +1,46 @@
+.closeButton {
+ z-index: 10;
+ border: 1px solid var(--bolt-border-primary);
+ border-radius: 100vh;
+ background: var(--bolt-surface-primary);
+ color: var(--bolt-content-primary);
+ font-size: var(--bolt-font-body-small-size);
+ padding: var(--bolt-space-2) var(--bolt-space-4);
+ line-height: 1;
+
+ cursor: pointer;
+ align-items: center;
+ justify-content: center;
+ display: none;
+}
+
+.closeButton:hover {
+ background: var(--bolt-surface-brand-primary-hovered);
+}
+
+.closeButton:focus-visible {
+ outline: 2px solid white;
+ outline-offset: 2px;
+}
+
+/* The dialog hosts the iframe and close button. Visually the iframe is the "dialog" */
+.iframe {
+ width: 100%;
+ height: 100%;
+ border: none;
+ display: block;
+ background-color: var(--bolt-surface-primary);
+}
+
+/* Turns into a dialog with a close button on larger screens */
+@media (width >= 768px) and (height >= 500px) {
+ .closeButton {
+ display: flex;
+ align-items: center;
+ gap: var(--bolt-space-2);
+ }
+
+ .iframe {
+ border-radius: 10px;
+ }
+}
diff --git a/client/src/pages/products/ad-action/AdAction.tsx b/client/src/pages/products/ad-action/AdAction.tsx
new file mode 100644
index 0000000..5ce52b8
--- /dev/null
+++ b/client/src/pages/products/ad-action/AdAction.tsx
@@ -0,0 +1,102 @@
+import { useEffect, useRef, useState } from "preact/hooks";
+import { Button } from "../../../design/button/Button";
+import styles from "./global-modal.module.css";
+import localStyles from "./AdAction.module.css";
+import { Close } from "../../../design/icons/close";
+
+export interface AdActionProps {
+ url: string;
+ label: string;
+}
+
+export function AdAction({ url, label }: AdActionProps) {
+ const dialogRef = useRef
(null);
+ const iframeRef = useRef(null);
+ const scrollPositionRef = useRef(0);
+ const [iframeKey, setIframeKey] = useState(0); // To force iframe reload on each open
+
+ const handleOpenModal = () => {
+ // Save current scroll position
+ scrollPositionRef.current = window.scrollY;
+
+ // Fix body position to prevent scroll jump
+ document.body.style.top = `-${scrollPositionRef.current}px`;
+ document.body.classList.add("modal-open");
+
+ dialogRef.current?.showModal();
+ iframeRef.current?.contentWindow?.postMessage(
+ { type: "bolt-gaming-start-ads" },
+ "*",
+ );
+ };
+
+ const handleCloseModal = () => {
+ dialogRef.current?.close();
+
+ // Restore body styles and scroll position
+ document.body.classList.remove("modal-open");
+ document.body.style.top = "";
+ window.scrollTo(0, scrollPositionRef.current);
+
+ setIframeKey((prev) => prev + 1);
+ };
+
+ useEffect(() => {
+ const dialog = dialogRef.current;
+ if (!dialog) return;
+
+ const controller = new AbortController();
+
+ dialog.addEventListener(
+ "close",
+ () => {
+ document.body.classList.remove("modal-open");
+ document.body.style.top = "";
+ window.scrollTo(0, scrollPositionRef.current);
+ },
+ controller,
+ );
+
+ window.addEventListener(
+ "message",
+ (event) => {
+ // bolt-bce-transaction-success, bolt-transaction-success
+ if (event.data.type === "bolt-gaming-issue-reward") {
+ handleCloseModal();
+ }
+ },
+ controller,
+ );
+
+ return () => {
+ controller.abort();
+ // Cleanup: restore scroll on unmount if dialog was open
+ document.body.classList.remove("modal-open");
+ document.body.style.top = "";
+ };
+ }, []);
+
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/client/src/pages/products/ad-action/CheckoutAction.tsx b/client/src/pages/products/ad-action/CheckoutAction.tsx
new file mode 100644
index 0000000..55a846c
--- /dev/null
+++ b/client/src/pages/products/ad-action/CheckoutAction.tsx
@@ -0,0 +1,102 @@
+import { useEffect, useRef, useState } from "preact/hooks";
+import { Button } from "../../../design/button/Button";
+import { Spinner } from "../../../design/spinner/Spinner";
+import styles from "./global-modal.module.css";
+import localStyles from "./AdAction.module.css";
+import { Close } from "../../../design/icons/close";
+import { getPaymentLink } from "../../../endpoints";
+
+export interface AdActionProps {
+ label: string;
+}
+
+export function CheckoutAction({ label }: AdActionProps) {
+ const dialogRef = useRef(null);
+ const scrollPositionRef = useRef(0);
+ const [paymentUrl, setPaymentUrl] = useState("");
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+
+ const handleOpenModal = async () => {
+ setIsLoading(true);
+ try {
+ const paymentLink = await getPaymentLink("gems-100");
+ console.log("Payment link retrieved:", paymentLink);
+ setPaymentUrl(paymentLink);
+ setIsModalOpen(true);
+
+ // Save current scroll position
+ scrollPositionRef.current = window.scrollY;
+
+ // Fix body position to prevent scroll jump
+ document.body.style.top = `-${scrollPositionRef.current}px`;
+ document.body.classList.add("modal-open");
+
+ dialogRef.current?.showModal();
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleCloseModal = () => {
+ dialogRef.current?.close();
+ setIsModalOpen(false);
+
+ // Restore body styles and scroll position
+ document.body.classList.remove("modal-open");
+ document.body.style.top = "";
+ window.scrollTo(0, scrollPositionRef.current);
+ };
+
+ useEffect(() => {
+ const dialog = dialogRef.current;
+ if (!dialog) return;
+
+ const controller = new AbortController();
+
+ dialog.addEventListener(
+ "close",
+ () => {
+ setIsModalOpen(false);
+ document.body.classList.remove("modal-open");
+ document.body.style.top = "";
+ window.scrollTo(0, scrollPositionRef.current);
+ },
+ controller,
+ );
+
+ return () => {
+ controller.abort();
+ // Cleanup: restore scroll on unmount if dialog was open
+ document.body.classList.remove("modal-open");
+ document.body.style.top = "";
+ };
+ }, []);
+
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/client/src/pages/products/ad-action/global-modal.module.css b/client/src/pages/products/ad-action/global-modal.module.css
new file mode 100644
index 0000000..e6cf72d
--- /dev/null
+++ b/client/src/pages/products/ad-action/global-modal.module.css
@@ -0,0 +1,42 @@
+/* taken from the ad site */
+
+.globalModal {
+ background: transparent;
+ border-width: 0;
+ box-sizing: content-box;
+ flex-direction: column;
+ max-height: 100dvh; /* override dialog defaults */
+ max-width: 100dvw; /* override dialog defaults */
+ height: 100dvh;
+ margin: auto;
+ width: 100dvw;
+ padding: 0;
+ position: relative;
+ overflow: visible;
+
+ /* Establish container for all child elements to use container queries */
+ container-type: size;
+ container-name: modal;
+}
+
+.globalModal[open] {
+ display: flex;
+ align-items: flex-end;
+ gap: var(--bolt-space-4);
+}
+
+.globalModal::backdrop {
+ background: rgba(0, 0, 0, 0.8);
+}
+
+@media (width >= 768px) and (height >= 500px) {
+ /* Default: Portrait mode */
+ .globalModal {
+ aspect-ratio: 9 / 16;
+ border-radius: 10px;
+ max-height: calc(100dvh - 4rem);
+ max-width: calc(100dvw - 4rem);
+ height: auto;
+ width: auto;
+ }
+}
diff --git a/client/src/pages/sdks/DeveloperSDKs.module.css b/client/src/pages/sdks/DeveloperSDKs.module.css
new file mode 100644
index 0000000..f80bdaf
--- /dev/null
+++ b/client/src/pages/sdks/DeveloperSDKs.module.css
@@ -0,0 +1,11 @@
+.divider {
+ margin-block: 4.5rem;
+
+ height: 1px;
+ border: 0;
+ border-top: 1px solid var(--bolt-border-secondary);
+}
+
+.previewImage {
+ width: 400px;
+}
diff --git a/client/src/pages/sdks/DeveloperSDKs.tsx b/client/src/pages/sdks/DeveloperSDKs.tsx
new file mode 100644
index 0000000..eb72903
--- /dev/null
+++ b/client/src/pages/sdks/DeveloperSDKs.tsx
@@ -0,0 +1,70 @@
+import { PageLayout } from "../../components/page-layout/PageLayout";
+import { Section, Sections } from "../../components/section/Section";
+import { Heading1 } from "../../design/heading/Heading";
+import { TextBlock } from "../../design/text-block/TextBlock";
+
+import PreviewUnity from "../../assets/preview-unity.png";
+
+import PreviewJS from "../../assets/preview-js.jpg";
+
+import styles from "./DeveloperSDKs.module.css";
+import { LinkButton } from "../../design/button/Button";
+
+export function DevelopmentSDKs() {
+ return (
+
+
+
+ Developer SDKs
+
+ Integrate ads into your game to generate additional revenue
+
+
+
+
+
+
+
+ Go to Unity SDK
+
+ }
+ preview={
+
+ }
+ />
+
+
+ Go to TypeScript SDK
+
+ }
+ preview={
+
+ }
+ />
+
+
+
+ );
+}
diff --git a/client/src/routes/root.tsx b/client/src/routes/root.tsx
index 6f986a1..a5d29de 100644
--- a/client/src/routes/root.tsx
+++ b/client/src/routes/root.tsx
@@ -2,7 +2,7 @@ import { createRootRoute, Outlet, createRoute } from "@tanstack/react-router";
import { ToastContainer } from "react-toastify";
-import { TopNav } from "../components/TopNav";
+import { TopNav } from "../components/top-nav/TopNav";
export const rootRoute = createRootRoute({
component: () => ,
diff --git a/client/src/routes/sdks.tsx b/client/src/routes/sdks.tsx
new file mode 100644
index 0000000..cf431e9
--- /dev/null
+++ b/client/src/routes/sdks.tsx
@@ -0,0 +1,13 @@
+import { createRoute } from "@tanstack/react-router";
+import { standardLayoutRoute } from "./root";
+import { DevelopmentSDKs } from "../pages/sdks/DeveloperSDKs";
+
+export function SDKsPage() {
+ return ;
+}
+
+export const sdksRoute = createRoute({
+ path: "/sdks",
+ component: SDKsPage,
+ getParentRoute: () => standardLayoutRoute,
+});
diff --git a/server/src/routes/bolt.ts b/server/src/routes/bolt.ts
index 495d4ea..a1a025e 100644
--- a/server/src/routes/bolt.ts
+++ b/server/src/routes/bolt.ts
@@ -81,7 +81,8 @@ router.get("/products/:sku/checkout-link", async (req, res) => {
}
});
-router.post("/products/:sku/payment-link", authenticateToken, (req, res) => {
+// removed authenticateToken for demo
+router.post("/products/:sku/payment-link", (req, res) => {
try {
const { sku } = req.params;
@@ -90,15 +91,15 @@ router.post("/products/:sku/payment-link", authenticateToken, (req, res) => {
return res.status(404).json({ error: "Product sku not found" });
}
- const paymentLinkRequest: CreatePaymentLinkRequest = {
+ // Don't type as CreatePaymentLinkRequest because of redirect_url optionality
+ const paymentLinkRequest: any = {
item: {
price: Math.floor(product.price * 100),
name: product.name,
currency: "USD",
image_url: getAssetUrlForSku(sku),
},
- redirect_url: "https://example.com/checkout/success",
- user_id: req.user!.id,
+ user_id: "user_123",
game_id: env.bolt.gameId,
metadata: {
sku: product.sku,
@@ -106,7 +107,7 @@ router.post("/products/:sku/payment-link", authenticateToken, (req, res) => {
};
boltApi.gaming
- .createPaymentLink(paymentLinkRequest)
+ .createPaymentLink(paymentLinkRequest as CreatePaymentLinkRequest)
.then((response) => {
console.log("Created payment link:", response);
res.json({ success: true, data: response });