diff --git a/client/src/app.css b/client/src/app.css index 6a28d58..4629354 100644 --- a/client/src/app.css +++ b/client/src/app.css @@ -9,7 +9,14 @@ } body { - all: unset; + margin: 0; + padding: 0; +} + +body.modal-open { + position: fixed; + width: 100%; + overflow: hidden; } p { diff --git a/client/src/app.tsx b/client/src/app.tsx index 1c7a8f5..c40f281 100644 --- a/client/src/app.tsx +++ b/client/src/app.tsx @@ -11,9 +11,10 @@ import { zappyBirdRoute, zappyLayoutRoute } from "./routes/zappy-bird"; import { useUserProfile } from "./endpoints"; import { useBoltSessionVerification } from "./hooks/useBoltSessionVerification"; import { productsRoute } from "./routes/products"; +import { sdksRoute } from "./routes/sdks"; const routeTree = rootRoute.addChildren([ - standardLayoutRoute.addChildren([productsRoute]), + standardLayoutRoute.addChildren([productsRoute, sdksRoute]), zappyLayoutRoute.addChildren([zappyBirdRoute]), ]); diff --git a/client/src/assets/preview-js.jpg b/client/src/assets/preview-js.jpg new file mode 100644 index 0000000..2ad73b2 Binary files /dev/null and b/client/src/assets/preview-js.jpg differ diff --git a/client/src/assets/preview-unity.png b/client/src/assets/preview-unity.png new file mode 100644 index 0000000..411030e Binary files /dev/null and b/client/src/assets/preview-unity.png differ diff --git a/client/src/components/page-layout/PageLayout.module.css b/client/src/components/page-layout/PageLayout.module.css new file mode 100644 index 0000000..2003330 --- /dev/null +++ b/client/src/components/page-layout/PageLayout.module.css @@ -0,0 +1,58 @@ +.pageLayout { + --surface-purple: #f8f6fe; + + position: relative; + background-color: var(--surface-purple); + overflow: hidden; + min-height: 100vh; + display: flex; + justify-content: center; +} + +.pageLayout::before { + content: ""; + position: absolute; + top: 0; + right: 0; + width: 364px; + height: 640px; + background-image: url("../../assets/brand-background-top-corner.svg"); + background-size: contain; + background-repeat: no-repeat; + filter: blur(4px); + pointer-events: none; + z-index: 0; +} + +.pageLayout::after { + content: ""; + position: absolute; + bottom: 0; + left: 0; + width: 364px; + height: 640px; + background-image: url("../../assets/brand-background-bottom-corner.svg"); + background-size: contain; + background-repeat: no-repeat; + filter: blur(2px); + pointer-events: none; + z-index: 0; +} + +.content { + position: relative; + max-width: 915px; + width: 100%; + margin-inline: 2rem; + margin-block: 6rem; + z-index: 1; +} + +.hero { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--bolt-space-2); + + margin-bottom: var(--bolt-space-8); +} diff --git a/client/src/components/page-layout/PageLayout.tsx b/client/src/components/page-layout/PageLayout.tsx new file mode 100644 index 0000000..1450265 --- /dev/null +++ b/client/src/components/page-layout/PageLayout.tsx @@ -0,0 +1,31 @@ +import type { JSX } from "preact/jsx-runtime"; +import styles from "./PageLayout.module.css"; + +export interface PageLayoutProps { + children: JSX.Element; +} + +function PageLayoutRoot({ children }: PageLayoutProps) { + return
{children}
; +} + +export interface PageLayoutContentProps { + children: JSX.Element | JSX.Element[]; +} + +function Content({ children }: PageLayoutContentProps) { + return
{children}
; +} + +export interface PageLayoutHeroProps { + children: JSX.Element | JSX.Element[]; +} + +function Hero({ children }: PageLayoutHeroProps) { + return
{children}
; +} + +export const PageLayout = Object.assign(PageLayoutRoot, { + Hero, + Content, +}); diff --git a/client/src/pages/products/Section.module.css b/client/src/components/section/Section.module.css similarity index 53% rename from client/src/pages/products/Section.module.css rename to client/src/components/section/Section.module.css index 2c46018..d2da824 100644 --- a/client/src/pages/products/Section.module.css +++ b/client/src/components/section/Section.module.css @@ -1,3 +1,10 @@ +.sections { + display: grid; + grid-template-columns: 1fr max-content; + gap: 7rem; + margin-block-start: 4.5rem; +} + .section { display: grid; grid-template-columns: subgrid; @@ -12,31 +19,12 @@ width: fit-content; } -.sectionContent button { - margin-top: var(--bolt-space-6); +.sectionContent > img:first-child { + margin-bottom: var(--bolt-space-2); } -.preview { - display: flex; - align-items: center; -} - -.preview img { - user-select: none; - pointer-events: none; -} -.previewAd { - opacity: 1; - height: 400px; - z-index: 1; - border-radius: var(--bolt-border-radius-lg); - box-shadow: var(--global-shadow3); -} - -.previewGame { - opacity: 0.5; - height: 250px; - margin-left: -80px; +.sectionContent > :is(button, a) { + margin-top: var(--bolt-space-6); } @media (width < 768px) { diff --git a/client/src/components/section/Section.tsx b/client/src/components/section/Section.tsx new file mode 100644 index 0000000..81c00fd --- /dev/null +++ b/client/src/components/section/Section.tsx @@ -0,0 +1,39 @@ +import type { JSX } from "preact/jsx-runtime"; +import { Heading1 } from "../../design/heading/Heading"; +import { TextBlock } from "../../design/text-block/TextBlock"; + +import styles from "./Section.module.css"; + +export interface SectionProps { + iconUrl?: string; + iconSize?: number; + title: string; + description: string; + action: JSX.Element; + preview: JSX.Element | JSX.Element[]; +} + +export function Section(props: SectionProps) { + const { iconUrl, iconSize, title, description, action, preview } = props; + return ( +
+
+ {iconUrl && {title}} + {title} + {description} + + {action} +
+ + {preview} +
+ ); +} + +export interface SectionsProps { + children: JSX.Element | JSX.Element[]; +} + +export function Sections({ children }: SectionsProps) { + return
{children}
; +} diff --git a/client/src/components/TopNav.module.css b/client/src/components/top-nav/TopNav.module.css similarity index 79% rename from client/src/components/TopNav.module.css rename to client/src/components/top-nav/TopNav.module.css index 5a4180e..d8cccec 100644 --- a/client/src/components/TopNav.module.css +++ b/client/src/components/top-nav/TopNav.module.css @@ -2,25 +2,32 @@ padding: var(--bolt-space-4) var(--bolt-space-8); background-color: var(--bolt-surface-primary); box-shadow: var(--global-shadow2); + position: sticky; + top: 0; + z-index: 100; } .navLeft { display: flex; align-items: center; - gap: var(--bolt-space-4); + gap: 4rem; } .navLinks { margin-top: 0.5rem; display: flex; - gap: var(--bolt-space-4); + gap: var(--bolt-space-8); } .navLink { - color: var(--bolt-content-primary); + color: var(--bolt-content-secondary); text-decoration: none; } +.active { + color: var(--bolt-content-primary); +} + .active::after { content: ""; display: block; diff --git a/client/src/components/TopNav.tsx b/client/src/components/top-nav/TopNav.tsx similarity index 76% rename from client/src/components/TopNav.tsx rename to client/src/components/top-nav/TopNav.tsx index 36d2100..5f09bd1 100644 --- a/client/src/components/TopNav.tsx +++ b/client/src/components/top-nav/TopNav.tsx @@ -1,6 +1,6 @@ import { Link } from "@tanstack/react-router"; -import BoltLightningGames from "../assets/lightning-games.svg"; +import BoltLightningGames from "../../assets/lightning-games.svg"; import styles from "./TopNav.module.css"; @@ -25,6 +25,12 @@ export function TopNav() { activeProps={{ className: styles.active }}> Products + + Development SDKs + diff --git a/client/src/design/button/Button.module.css b/client/src/design/button/Button.module.css index 37e456e..b2e7507 100644 --- a/client/src/design/button/Button.module.css +++ b/client/src/design/button/Button.module.css @@ -3,6 +3,12 @@ padding: var(--bolt-space-3) var(--bolt-space-5); background-color: var(--bolt-surface-inverse-primary); color: var(--bolt-content-inverse-primary); + font-size: var(--bolt-font-body-small-size); + text-decoration: none; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: var(--bolt-space-2); } .button:hover { @@ -12,3 +18,7 @@ .button:active { background-color: var(--bolt-surface-inverse-primary-pressed); } + +.button:disabled { + cursor: not-allowed; +} diff --git a/client/src/design/button/Button.tsx b/client/src/design/button/Button.tsx index 69b46ac..b6ff90d 100644 --- a/client/src/design/button/Button.tsx +++ b/client/src/design/button/Button.tsx @@ -2,8 +2,16 @@ import type { JSX } from "preact/jsx-runtime"; import styles from "./Button.module.css"; -export type ButtonProps = JSX.HTMLAttributes; +export type ButtonProps = JSX.HTMLAttributes & { + disabled?: boolean; +}; export function Button(props: ButtonProps) { return ; } + +export type LinkButtonProps = JSX.AnchorHTMLAttributes; + +export function LinkButton(props: LinkButtonProps) { + return ; +} diff --git a/client/src/design/icons/close.tsx b/client/src/design/icons/close.tsx new file mode 100644 index 0000000..abfb990 --- /dev/null +++ b/client/src/design/icons/close.tsx @@ -0,0 +1,19 @@ +export function Close({ size = 24 }: { size?: number }) { + return ( + + + + + ); +} diff --git a/client/src/design/icons/spinner.tsx b/client/src/design/icons/spinner.tsx new file mode 100644 index 0000000..358aa9b --- /dev/null +++ b/client/src/design/icons/spinner.tsx @@ -0,0 +1,33 @@ +export function SpinnerIcon({ + size = 66, + className, +}: { + size?: number; + className?: string; +}) { + return ( + + + + + ); +} diff --git a/client/src/design/spinner/Spinner.module.css b/client/src/design/spinner/Spinner.module.css new file mode 100644 index 0000000..8202838 --- /dev/null +++ b/client/src/design/spinner/Spinner.module.css @@ -0,0 +1,12 @@ +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.spinner { + animation: spin 1s linear infinite; +} diff --git a/client/src/design/spinner/Spinner.tsx b/client/src/design/spinner/Spinner.tsx new file mode 100644 index 0000000..4f30477 --- /dev/null +++ b/client/src/design/spinner/Spinner.tsx @@ -0,0 +1,11 @@ +import { SpinnerIcon } from "../icons/spinner"; + +import styles from "./Spinner.module.css"; + +export type SpinnerProps = { + size?: number; +}; + +export function Spinner({ size = 24 }: SpinnerProps) { + return ; +} diff --git a/client/src/design/tabs/Tabs.module.css b/client/src/design/tabs/Tabs.module.css index cebbc50..1523d9a 100644 --- a/client/src/design/tabs/Tabs.module.css +++ b/client/src/design/tabs/Tabs.module.css @@ -70,7 +70,7 @@ } .tabPanel { - padding: var(--bolt-space-6) 0; + margin: var(--bolt-space-6) 0; outline: none; transition: opacity 0.15s ease-in-out; } diff --git a/client/src/pages/products/Product.module.css b/client/src/pages/products/Product.module.css index dc620db..d0e1b03 100644 --- a/client/src/pages/products/Product.module.css +++ b/client/src/pages/products/Product.module.css @@ -1,63 +1,28 @@ -.page { - --surface-purple: #f8f6fe; - - position: relative; - background-color: var(--surface-purple); - overflow: hidden; - min-height: 100vh; +.preview { display: flex; - justify-content: center; -} - -.page::before { - content: ""; - position: absolute; - top: 0; - right: 0; - width: 364px; - height: 640px; - background-image: url("../../assets/brand-background-top-corner.svg"); - background-size: contain; - background-repeat: no-repeat; - filter: blur(8px); - pointer-events: none; - z-index: 0; + align-items: center; + position: relative; + width: 300px; } -.page::after { - content: ""; - position: absolute; - bottom: 0; - left: 0; - width: 364px; - height: 640px; - background-image: url("../../assets/brand-background-bottom-corner.svg"); - background-size: contain; - background-repeat: no-repeat; - filter: blur(8px); +.preview img { + user-select: none; pointer-events: none; - z-index: 0; + flex-shrink: 0; } - -.mainContent { - position: relative; - max-width: 915px; - width: 100%; - margin-inline: 2rem; - margin-block: 6rem; +.previewAd { + opacity: 1; + height: 400px; z-index: 1; + border-radius: var(--bolt-border-radius-lg); + box-shadow: var(--global-shadow3); } -.hero { - display: flex; - flex-direction: column; - align-items: center; - - margin-bottom: var(--bolt-space-8); -} - -.sections { - display: grid; - grid-template-columns: 1fr max-content; - gap: 7rem; +.previewGame { + opacity: 0.5; + height: 250px; + width: 200px; + object-fit: cover; + position: absolute; + right: 0; } diff --git a/client/src/pages/products/Products.tsx b/client/src/pages/products/Products.tsx index 8f786b3..a12d6d8 100644 --- a/client/src/pages/products/Products.tsx +++ b/client/src/pages/products/Products.tsx @@ -1,5 +1,5 @@ import Tabs from "../../design/tabs/Tabs"; -import { Section } from "./Section"; +import { Section, Sections } from "../../components/section/Section"; import IconSwipeableAds from "../../assets/icon-swipeable-ads.png"; import PreviewSwipeableAd from "../../assets/preview-swipeable-ad.png"; @@ -7,7 +7,7 @@ import PreviewSwipeableGame from "../../assets/preview-swipeable-game.png"; import IconCarouselAds from "../../assets/icon-carousel-ads.png"; import PreviewCarouselAd from "../../assets/preview-carousel-ad.png"; -import PreviewCarouselGame from "../../assets/preview-video-game.jpg"; +import PreviewCarouselGame from "../../assets/preview-carousel-game.jpg"; import IconVideoAds from "../../assets/icon-video-ads.png"; import PreviewVideoAd from "../../assets/preview-video-ad.png"; @@ -22,24 +22,26 @@ import PreviewCheckoutGame from "../../assets/preview-checkout-game.png"; import styles from "./Product.module.css"; import { Heading1 } from "../../design/heading/Heading"; import { TextBlock } from "../../design/text-block/TextBlock"; +import { PageLayout } from "../../components/page-layout/PageLayout"; +import { AdAction } from "./ad-action/AdAction"; +import { CheckoutAction } from "./ad-action/CheckoutAction"; export default function Products() { return ( -
-
-
+ + + Products Icon Gaming Products Explore the different products BoltPlay offers within gaming -
+ -
-
+ + ); } function AdsProductContent() { return ( -
+
+ } + preview={ + + } />
+ } + preview={ + + } />
+ } + preview={} /> -
+ ); } function CheckoutProductContent() { return ( -
+
} + preview={ + + } /> + + ); +} + +function Preview({ adUrl, gameUrl }: { adUrl: string; gameUrl: string }) { + return ( +
+ Preview Ad + Preview Game
); } 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 ( -
-
- Swipe-able Ads - {title} - {description} - - -
- -
- Preview Ad - Preview Game -
-
- ); -} 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 ( + <> + + + + +