From 191481d2ccbc040054b2f0ceaf4b9385f02a8ac3 Mon Sep 17 00:00:00 2001 From: Liz Sweigart <127434495+NotThatKindOfDrLiz@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:55:03 -0500 Subject: [PATCH 01/19] feat: add showcase-mode homepage with docs, downloads, and a curated reel Introduce a build-time WEB_MODE flag (VITE_WEB_MODE, default "showcase") that turns divine.video into a documents + app-download + curated-taste surface, with the legacy Nostr client lazy-loaded behind "full" mode. Showcase mode: - Homepage: hero + device-aware "Get the app" CTA (iOS -> App Store, Android -> Play/Zapstore dropdown, desktop -> all three; clicks tracked) and a phone-framed, swipeable reel of curated video. - Curated reel reads an allowlisted curator's titled kind 30005 list (matched by title, since the mobile app auto-generates d tags), unions e-tag and a-tag video refs, and applies an all-ages safety floor (drops age-restricted / content-warned clips). Shuffled per visit. - Reel: read-only (no like/follow/comment), start-muted with an unmute toggle, tap top/bottom to navigate (scrolls only the reel, not the page), share via native sheet on touch / copy-link on desktop, white wordmark watermark. Square classics letterbox to preserve aspect. - Public single-video share page at /video/:id, safety-gated. - Router splits showcase vs full; keeps /auth/callback, /app/callback, and /invite/:code for mobile deep-links. Edge worker stops injecting the trending feed / OG image on the curated homepage in showcase mode. Notable fixes: resolve creator names via display_name (was dropping ~half to generic names); reuse fetchListVideos across ListDetailPage and the reel; "Link copied" toast auto-dismisses. --- compute-js/src/index.js | 12 +- compute-js/src/webMode.js | 20 +++ public/store-badges/zapstore-badge.svg | 7 + src/AppRouter.test.tsx | 26 ++- src/AppRouter.tsx | 190 ++-------------------- src/components/DownloadRow.test.tsx | 69 ++++++++ src/components/DownloadRow.tsx | 90 ++++++++++ src/components/GetAppButton.tsx | 120 ++++++++++++++ src/components/MarketingHeader.tsx | 134 +++++++++------ src/components/family/StoreBadgesCta.tsx | 17 +- src/components/showcase/PhoneFrame.tsx | 43 +++++ src/components/showcase/ShowcasePhone.tsx | 29 ++++ src/components/showcase/ShowcaseReel.tsx | 132 +++++++++++++++ src/components/showcase/ShowcaseSlide.tsx | 178 ++++++++++++++++++++ src/components/static-pages/index.ts | 2 +- src/config/curation.test.ts | 51 ++++++ src/config/curation.ts | 95 +++++++++++ src/config/webMode.ts | 22 +++ src/hooks/useCuratedShowcase.test.ts | 98 +++++++++++ src/hooks/useCuratedShowcase.ts | 170 +++++++++++++++++++ src/hooks/useShowcaseShare.test.ts | 51 ++++++ src/hooks/useShowcaseShare.ts | 56 +++++++ src/hooks/useShowcaseVideo.ts | 60 +++++++ src/lib/appStoreOptions.test.ts | 38 +++++ src/lib/appStoreOptions.ts | 42 +++++ src/lib/detectPlatform.test.ts | 37 +++++ src/lib/detectPlatform.ts | 31 ++++ src/lib/fetchListVideos.test.ts | 116 +++++++++++++ src/lib/fetchListVideos.ts | 154 ++++++++++++++++++ src/lib/mobileStoreLinks.ts | 34 ++++ src/lib/parseVideoListFromEvent.test.ts | 16 ++ src/lib/parseVideoListFromEvent.ts | 13 ++ src/lib/showcaseDisplayName.test.ts | 38 +++++ src/lib/showcaseDisplayName.ts | 26 +++ src/lib/showcaseSafety.test.ts | 94 +++++++++++ src/lib/showcaseSafety.ts | 49 ++++++ src/lib/showcaseVideoFit.test.ts | 43 +++++ src/lib/showcaseVideoFit.ts | 25 +++ src/lib/shuffle.test.ts | 35 ++++ src/lib/shuffle.ts | 17 ++ src/pages/ListDetailPage.tsx | 97 +---------- src/pages/OpenSourcePage.tsx | 10 +- src/pages/ShowcasePage.tsx | 83 ++++++++++ src/pages/ShowcaseVideoPage.tsx | 113 +++++++++++++ src/pages/family/familyPages.test.tsx | 10 +- src/routes/FullAppRoutes.tsx | 139 ++++++++++++++++ src/routes/ShowcaseRoutes.test.tsx | 111 +++++++++++++ src/routes/ShowcaseRoutes.tsx | 65 ++++++++ src/routes/devRoutes.tsx | 38 +++++ src/routes/documentRoutes.tsx | 57 +++++++ src/styles/brand-utilities.css | 10 ++ tests/visual/a11y.spec.ts | 4 +- tests/visual/responsive.spec.ts | 91 +++++++++++ 53 files changed, 2960 insertions(+), 348 deletions(-) create mode 100644 compute-js/src/webMode.js create mode 100644 public/store-badges/zapstore-badge.svg create mode 100644 src/components/DownloadRow.test.tsx create mode 100644 src/components/DownloadRow.tsx create mode 100644 src/components/GetAppButton.tsx create mode 100644 src/components/showcase/PhoneFrame.tsx create mode 100644 src/components/showcase/ShowcasePhone.tsx create mode 100644 src/components/showcase/ShowcaseReel.tsx create mode 100644 src/components/showcase/ShowcaseSlide.tsx create mode 100644 src/config/curation.test.ts create mode 100644 src/config/curation.ts create mode 100644 src/config/webMode.ts create mode 100644 src/hooks/useCuratedShowcase.test.ts create mode 100644 src/hooks/useCuratedShowcase.ts create mode 100644 src/hooks/useShowcaseShare.test.ts create mode 100644 src/hooks/useShowcaseShare.ts create mode 100644 src/hooks/useShowcaseVideo.ts create mode 100644 src/lib/appStoreOptions.test.ts create mode 100644 src/lib/appStoreOptions.ts create mode 100644 src/lib/detectPlatform.test.ts create mode 100644 src/lib/detectPlatform.ts create mode 100644 src/lib/fetchListVideos.test.ts create mode 100644 src/lib/fetchListVideos.ts create mode 100644 src/lib/showcaseDisplayName.test.ts create mode 100644 src/lib/showcaseDisplayName.ts create mode 100644 src/lib/showcaseSafety.test.ts create mode 100644 src/lib/showcaseSafety.ts create mode 100644 src/lib/showcaseVideoFit.test.ts create mode 100644 src/lib/showcaseVideoFit.ts create mode 100644 src/lib/shuffle.test.ts create mode 100644 src/lib/shuffle.ts create mode 100644 src/pages/ShowcasePage.tsx create mode 100644 src/pages/ShowcaseVideoPage.tsx create mode 100644 src/routes/FullAppRoutes.tsx create mode 100644 src/routes/ShowcaseRoutes.test.tsx create mode 100644 src/routes/ShowcaseRoutes.tsx create mode 100644 src/routes/devRoutes.tsx create mode 100644 src/routes/documentRoutes.tsx create mode 100644 tests/visual/responsive.spec.ts diff --git a/compute-js/src/index.js b/compute-js/src/index.js index fd4a4ce1c..70a0c5c1a 100644 --- a/compute-js/src/index.js +++ b/compute-js/src/index.js @@ -25,6 +25,7 @@ import { import { transformVideoApiResponse } from './videoMetadata.js'; import { renderEmbedPage } from './embedPage.js'; import { resolveFeedInjectedHtml } from './feedInjection.js'; +import { isShowcaseMode } from './webMode.js'; const publisherServer = PublisherServer.fromStaticPublishRc(rc); const DEFAULT_OG_IMAGE = 'https://divine.video/og.png'; @@ -305,10 +306,17 @@ async function handleRequest(event) { const isApexDomain = APEX_DOMAINS.includes(hostnameToUse); const isApexLanding = isApexDomain && (url.pathname === '/' || url.pathname === '/index.html'); const discoveryFeedType = isApexDomain ? getDiscoveryFeedType(url.pathname) : null; - const shouldInjectFeed = isApexLanding || discoveryFeedType; + // In showcase mode the homepage renders a hand-curated, all-ages set and the + // /discovery routes do not exist. Injecting the trending feed here would put + // uncurated content on the one page that exists to guarantee curation, so the + // LCP optimization is switched off rather than pointed somewhere else. + const shouldInjectFeed = !isShowcaseMode() && (isApexLanding || discoveryFeedType); if (isApexLanding && isSocialMediaCrawler(request)) { - const ogResponse = await handleApexOgTags(); + // Same reasoning: handleApexOgTags() seeds the social preview image from the + // top trending video, which is not curated. Fall through to the static + // shell's own OG tags in showcase mode. + const ogResponse = isShowcaseMode() ? null : await handleApexOgTags(); if (ogResponse) return ogResponse; } diff --git a/compute-js/src/webMode.js b/compute-js/src/webMode.js new file mode 100644 index 000000000..247d3d23f --- /dev/null +++ b/compute-js/src/webMode.js @@ -0,0 +1,20 @@ +// ABOUTME: Edge-side mirror of the frontend VITE_WEB_MODE flag +// ABOUTME: Decides whether the worker may inject uncurated feed data into pages + +/** + * Which experience the site serves. MUST stay in sync with `VITE_WEB_MODE` + * in `src/config/webMode.ts`. + * + * The worker has no access to Vite's env, and both are deployed together from + * this repo (`npm run fastly:deploy && npm run fastly:publish`), so a checked-in + * constant is the sync mechanism. If you flip one, flip the other in the same + * commit — a mismatch means the edge injects trending videos into a page whose + * whole purpose is that its content is curated. + * + * @type {'showcase' | 'full'} + */ +export const WEB_MODE = 'showcase'; + +export function isShowcaseMode() { + return WEB_MODE === 'showcase'; +} diff --git a/public/store-badges/zapstore-badge.svg b/public/store-badges/zapstore-badge.svg new file mode 100644 index 000000000..4a18a966a --- /dev/null +++ b/public/store-badges/zapstore-badge.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/AppRouter.test.tsx b/src/AppRouter.test.tsx index bb58e14fa..8f1a3903e 100644 --- a/src/AppRouter.test.tsx +++ b/src/AppRouter.test.tsx @@ -3,6 +3,12 @@ import { render, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import AppRouter from './AppRouter'; +// These cases cover the legacy client, which only ships when VITE_WEB_MODE=full. +vi.mock('./config/webMode', () => ({ + WEB_MODE: 'full', + isShowcaseMode: () => false, +})); + const { mockUseCurrentUser } = vi.hoisted(() => ({ mockUseCurrentUser: vi.fn(() => ({ user: undefined, @@ -10,11 +16,14 @@ const { mockUseCurrentUser } = vi.hoisted(() => ({ })), })); -vi.mock('./hooks/useCurrentUser', () => ({ +// FullAppRoutes imports these through the `@/` alias, so the mocks have to use +// the same specifier — a relative path registers a different module key when +// the suite runs alongside other files. +vi.mock('@/hooks/useCurrentUser', () => ({ useCurrentUser: () => mockUseCurrentUser(), })); -vi.mock('./hooks/useSubdomainUser', () => ({ +vi.mock('@/hooks/useSubdomainUser', () => ({ getSubdomainUser: () => null, })); @@ -34,11 +43,11 @@ vi.mock('@/components/AppLayout', () => ({ AppLayout: () => , })); -vi.mock('./pages/AnalyticsPage', () => ({ +vi.mock('@/pages/AnalyticsPage', () => ({ default: () =>
, })); -vi.mock('./pages/NIP19Page', () => ({ +vi.mock('@/pages/NIP19Page', () => ({ NIP19Page: () =>
, })); @@ -52,12 +61,17 @@ describe('AppRouter', () => { window.history.pushState({}, '', '/'); }); - it('keeps analytics routed while a saved session is restoring', () => { + // The full route table is a lazy chunk now, so this has to await the import. + it('keeps analytics routed while a saved session is restoring', async () => { window.history.pushState({}, '', '/analytics'); render(); - expect(screen.getByTestId('analytics-page')).toBeInTheDocument(); + // Generous timeout: resolving this lazy chunk pulls in the whole legacy page + // graph, which can exceed the 1s default when the full suite is running. + expect( + await screen.findByTestId('analytics-page', {}, { timeout: 15000 }), + ).toBeInTheDocument(); expect(screen.queryByTestId('nip19-page')).not.toBeInTheDocument(); }); }); diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index a539aed43..5efe6b99f 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -3,192 +3,32 @@ */ import { lazy, Suspense } from "react"; -import { BrowserRouter, Route, Routes } from "react-router-dom"; +import { BrowserRouter } from "react-router-dom"; import { ScrollToTop } from "./components/ScrollToTop"; import { AnalyticsPageTracker } from "./components/AnalyticsPageTracker"; import { AnalyticsUserTracker } from "./components/AnalyticsUserTracker"; -import { getSubdomainUser } from "./hooks/useSubdomainUser"; -import { useCurrentUser } from "./hooks/useCurrentUser"; +import { isShowcaseMode } from "./config/webMode"; +import ShowcaseRoutes from "./routes/ShowcaseRoutes"; -import Index from "./pages/Index"; -import { NIP19Page } from "./pages/NIP19Page"; -import NotFound from "./pages/NotFound"; -import HomePage from "./pages/HomePage"; -import DiscoveryPage from "./pages/DiscoveryPage"; -import TrendingPage from "./pages/TrendingPage"; -import PopularPage from "./pages/PopularPage"; -import HashtagPage from "./pages/HashtagPage"; -import CategoryPage from "./pages/CategoryPage"; -import CategoriesIndexPage from "./pages/CategoriesIndexPage"; -import HashtagDiscoveryPage from "./pages/HashtagDiscoveryPage"; -import ProfilePage from "./pages/ProfilePage"; -import SearchPage from "./pages/SearchPage"; -import VideoPage from "./pages/VideoPage"; -import { LegacyVineVideoPage } from "./pages/LegacyVineVideoPage"; -import { TagPage } from "./pages/TagPage"; -import ListsPage from "./pages/ListsPage"; -import ListDetailPage from "./pages/ListDetailPage"; -import ModerationSettingsPage from "./pages/ModerationSettingsPage"; -import LinkedAccountsSettingsPage from "./pages/LinkedAccountsSettingsPage"; -// import { NIP05ProfilePage } from "./pages/NIP05ProfilePage"; -import { UniversalUserPage } from "./pages/UniversalUserPage"; -import EventPage from "./pages/EventPage"; - -import PrivacyPage from "./pages/PrivacyPage"; -import OpenSourcePage from "./pages/OpenSourcePage"; -import ProofModePage from "./pages/ProofModePage"; -import AuthenticityPage from "./pages/AuthenticityPage"; -import DMCAPage from "./pages/DMCAPage"; -import HumanCreatedPage from "./pages/HumanCreatedPage"; -import { SafetyPage } from "./pages/SafetyPage"; -import { FamilyHubPage } from "./pages/family/FamilyHubPage"; -import { TalkingToYourTeenPage } from "./pages/family/TalkingToYourTeenPage"; -import { MediaPlanPage } from "./pages/family/MediaPlanPage"; -import { WhenSomethingGoesWrongPage } from "./pages/family/WhenSomethingGoesWrongPage"; -import { SafetyToolsPage } from "./pages/family/SafetyToolsPage"; -import { AgeReviewPage } from "./pages/AgeReviewPage"; -import { KidsPolicyPage } from "./pages/KidsPolicyPage"; -import { Support } from "./pages/Support"; -import { FAQPage } from "./pages/FAQPage"; -import MerchPage from "./pages/MerchPage"; -import { TermsPage } from "./pages/TermsPage"; -import GetEmbedPage from "./pages/GetEmbedPage"; -import AppCallbackPage from "./pages/AppCallbackPage"; -import AuthCallbackPage from "./pages/AuthCallbackPage"; -import InvitesLandingPage from "./pages/InvitesLandingPage"; -import { AppLayout } from "@/components/AppLayout"; -import { DebugVideoPage } from "./pages/DebugVideoPage"; -import LeaderboardPage from "./pages/LeaderboardPage"; -import NotificationsPage from "./pages/NotificationsPage"; -import AnalyticsPage from "./pages/AnalyticsPage"; -import MessagesPage from "./pages/MessagesPage"; -import ConversationPage from "./pages/ConversationPage"; -import CollabsPage from "./pages/CollabsPage"; -// import { UploadPage } from "./pages/UploadPage"; // DISABLED: Upload route is commented out - -// Dev-only: static preview surface for the brand system. The `lazy()` call -// sits behind `import.meta.env.DEV` so Vite's dead-code elimination drops both -// the route registration AND the async chunk reference from production builds. -const BrandPreview = import.meta.env.DEV - ? lazy(() => import("./pages/_BrandPreview")) - : null; +// The legacy client is a separate lazy chunk so showcase builds never download +// it. Splitting here rather than per-page means the entire app shell — feeds, +// profiles, login, messages — is behind one dynamic import that showcase mode +// simply never reaches. +const FullAppRoutes = lazy(() => import("./routes/FullAppRoutes")); export function AppRouter() { - const { user, isResolvingJwt } = useCurrentUser(); - - // Treat an in-flight hosted-JWT session as "still determining auth", not - // "logged out" — otherwise the protected routes below unmount during the - // getPublicKey() round-trip and a reload bounces the user off the page. - // - // Tradeoff (intentional): while resolving, `user` is still undefined, so a - // protected page renders its own brief logged-out fallback (e.g. LoginArea) - // until the pubkey lands. That sub-second fallback is strictly better than the - // previous behavior, which unmounted the route entirely and discarded the URL. - // A resolving-aware loading state on protected pages is a possible follow-up; - // it's deliberately out of scope for this precedence fix. - const isLoggedIn = Boolean(user) || isResolvingJwt; - - // Check if we're on a subdomain profile (username.divine.video) - const subdomainUser = getSubdomainUser(); - - const appShellRoutes = ( - <> - {/* Public browsing routes - accessible without login */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - {/* Protected routes - require login */} - {isLoggedIn && ( - <> - } /> - } /> - } /> - } /> - } /> - } /> - {/* DISABLED: Upload route - not supported on web at this time - } /> - */} - } /> - } /> - } /> - } /> - {/* Test pages for debugging */} - } /> - - )} - - {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} - } /> - - ); - return ( - - {/* Marketing/informational pages - no app layout */} - {/* /about redirects to about.divine.video via _redirects (301) */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - {/* Dev-only brand primitives preview — tree-shaken in production */} - {import.meta.env.DEV && BrandPreview && ( - - - - } - /> - )} - - }> - {/* Home/landing route - render profile directly on subdomain */} - : } /> - {appShellRoutes} - - + {isShowcaseMode() ? ( + + ) : ( + + + + )} ); } diff --git a/src/components/DownloadRow.test.tsx b/src/components/DownloadRow.test.tsx new file mode 100644 index 000000000..cb5b0d492 --- /dev/null +++ b/src/components/DownloadRow.test.tsx @@ -0,0 +1,69 @@ +// ABOUTME: Tests for the homepage download row +// ABOUTME: Every distribution channel must be present and attributed + +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { DownloadRow } from './DownloadRow'; + +vi.mock('@/lib/analytics', () => ({ + trackEvent: vi.fn(), +})); + +function hrefFor(label: string): string { + return screen.getByLabelText(label).getAttribute('href') ?? ''; +} + +describe('DownloadRow', () => { + it('links to the three distribution channels', () => { + render(); + + expect(hrefFor('Download Divine on the App Store')).toContain('apps.apple.com'); + expect(hrefFor('Get Divine on Google Play')).toContain('play.google.com'); + expect(hrefFor('Get Divine on Zapstore')).toContain('zapstore'); + }); + + it('no longer surfaces a GitHub link', () => { + render(); + expect(screen.queryByLabelText(/github/i)).toBeNull(); + }); + + it('carries the iOS app id and Android package', () => { + render(); + + expect(hrefFor('Download Divine on the App Store')).toContain('id6747959501'); + expect(hrefFor('Get Divine on Google Play')).toContain('co.openvine.app'); + }); + + // Pinned exactly: Zapstore's path is /apps/ (plural) and a typo here produces + // a link that looks plausible but 404s. + it('points at the exact Zapstore listing', () => { + render(); + + expect(hrefFor('Get Divine on Zapstore')).toBe( + 'https://zapstore.dev/apps/co.openvine.app', + ); + }); + + it('tags store links with the given campaign and medium', () => { + render(); + + const appStore = hrefFor('Download Divine on the App Store'); + expect(appStore).toContain('utm_source=divine_site'); + expect(appStore).toContain('utm_medium=homepage'); + expect(appStore).toContain('utm_campaign=launch'); + }); + + it('defaults to homepage attribution', () => { + render(); + expect(hrefFor('Download Divine on the App Store')).toContain('utm_medium=homepage'); + }); + + it('opens external destinations safely', () => { + render(); + + for (const link of screen.getAllByRole('link')) { + expect(link).toHaveAttribute('target', '_blank'); + expect(link.getAttribute('rel')).toContain('noopener'); + } + }); +}); diff --git a/src/components/DownloadRow.tsx b/src/components/DownloadRow.tsx new file mode 100644 index 000000000..244e85846 --- /dev/null +++ b/src/components/DownloadRow.tsx @@ -0,0 +1,90 @@ +// ABOUTME: The three ways to get Divine — App Store, Google Play, Zapstore +// ABOUTME: Primary conversion surface for the showcase homepage; all links UTM-tagged + +import { trackEvent } from "@/lib/analytics"; +import { buildStoreLinks } from "@/lib/mobileStoreLinks"; + +type Store = "app_store" | "play_store" | "zapstore"; + +interface DownloadRowProps { + /** utm_campaign value; use the route slug */ + campaign?: string; + /** utm_medium value; identifies the surface */ + medium?: string; + className?: string; +} + +export function DownloadRow({ + campaign = "homepage", + medium = "homepage", + className, +}: DownloadRowProps) { + const links = buildStoreLinks(campaign, medium); + + const onClick = (store: Store) => { + trackEvent("store_badge_click", { + store, + utm_campaign: campaign, + utm_source: "divine_site", + utm_medium: medium, + }); + }; + + const badges: Array<{ + store: Store; + href: string; + label: string; + src: string; + alt: string; + /** Per-asset height so the visible pills match — the Google PNG carries + transparent padding, so it needs a taller box to render the same size. */ + imgClass: string; + }> = [ + { + store: "app_store", + href: links.appStore, + label: "Download Divine on the App Store", + src: "/store-badges/app-store-badge.svg", + alt: "Download on the App Store", + imgClass: "h-11 sm:h-12 w-auto", + }, + { + store: "play_store", + href: links.playStore, + label: "Get Divine on Google Play", + src: "/store-badges/google-play-badge.png", + alt: "Get it on Google Play", + imgClass: "h-[60px] sm:h-[65px] w-auto -ml-[9px] sm:-ml-[10px]", + }, + { + store: "zapstore", + href: links.zapstore, + label: "Get Divine on Zapstore", + src: "/store-badges/zapstore-badge.svg", + alt: "Get it on Zapstore", + imgClass: "h-11 sm:h-12 w-auto", + }, + ]; + + return ( +
+ {/* Left-justified row, all three badges the same visible height. Widths + differ with the badge art, which is fine — heights match. */} + +
+ ); +} diff --git a/src/components/GetAppButton.tsx b/src/components/GetAppButton.tsx new file mode 100644 index 000000000..3e21ebf14 --- /dev/null +++ b/src/components/GetAppButton.tsx @@ -0,0 +1,120 @@ +// ABOUTME: Device-aware "Get the app" CTA — direct store link or a store dropdown +// ABOUTME: iOS → App Store; Android → Play + Zapstore; desktop → all three; each click tracked + +import { Link } from "react-router-dom"; +import { CaretDown } from "@phosphor-icons/react"; +import { cn } from "@/lib/utils"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { isShowcaseMode } from "@/config/webMode"; +import { detectPlatform } from "@/lib/detectPlatform"; +import { storesForPlatform, type Store } from "@/lib/appStoreOptions"; +import { trackEvent } from "@/lib/analytics"; + +const DEFAULT_CAMPAIGN = "header"; +const DEFAULT_MEDIUM = "marketing_header"; + +const BASE_CLASS = + "inline-flex items-center gap-1.5 font-semibold bg-primary text-primary-foreground rounded-full hover:brightness-110 transition-colors whitespace-nowrap"; + +const SIZE_CLASS = { + sm: "px-3 sm:px-4 py-2 text-sm", + lg: "px-6 py-3 text-base", +} as const; + +const ARROW = ( + + + +); + +function track(store: Store, campaign: string, medium: string) { + trackEvent("store_badge_click", { + store, + utm_campaign: campaign, + utm_source: "divine_site", + utm_medium: medium, + }); +} + +interface GetAppButtonProps { + /** utm_campaign for clicks from this instance. */ + campaign?: string; + /** utm_medium identifying the surface (header vs homepage hero, …). */ + medium?: string; + /** Visual size — `sm` for the header, `lg` for a hero call-to-action. */ + size?: keyof typeof SIZE_CLASS; +} + +/** + * The app-download call-to-action. + * + * In full mode there is an in-browser feed, so this stays "Try it" → /discovery. + * In showcase mode it becomes a device-aware app-download control: a single + * store gets a direct link, multiple stores get a dropdown so the visitor + * chooses (the Android Play-vs-Zapstore case, and desktop's three). + */ +export function GetAppButton({ + campaign = DEFAULT_CAMPAIGN, + medium = DEFAULT_MEDIUM, + size = "sm", +}: GetAppButtonProps = {}) { + const ctaClass = cn(BASE_CLASS, SIZE_CLASS[size]); + + if (!isShowcaseMode()) { + return ( + + Try it + {ARROW} + + ); + } + + const stores = storesForPlatform(detectPlatform(), campaign, medium); + + // Single obvious store (iOS): straight link, no menu. + if (stores.length === 1) { + const only = stores[0]; + return ( + track(only.store, campaign, medium)} + className={ctaClass} + > + Get the app + {ARROW} + + ); + } + + // Multiple stores: let the visitor choose. + return ( + + + Get the app + + + + {stores.map((option) => ( + + track(option.store, campaign, medium)} + className="cursor-pointer" + > + {option.label} + + + ))} + + + ); +} diff --git a/src/components/MarketingHeader.tsx b/src/components/MarketingHeader.tsx index d2c2f4b40..6e1ec05bc 100644 --- a/src/components/MarketingHeader.tsx +++ b/src/components/MarketingHeader.tsx @@ -1,66 +1,102 @@ // ABOUTME: Shared header component for marketing and informational pages -// ABOUTME: Provides consistent navigation across About, FAQ, Press, Legal pages, etc. +// ABOUTME: Full nav on desktop; logo + CTA + slide-out menu on mobile +import { useState } from "react"; import { Link } from "react-router-dom"; import { useTranslation } from "react-i18next"; +import { List } from "@phosphor-icons/react"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { GetAppButton } from "@/components/GetAppButton"; + +interface NavItem { + label: string; + /** External destination, or an internal route via `to` */ + href?: string; + to?: string; +} export function MarketingHeader() { const { t } = useTranslation(); + const [menuOpen, setMenuOpen] = useState(false); + + const navItems: NavItem[] = [ + { label: "About", href: "https://about.divine.video/" }, + { label: "Blog", href: "https://about.divine.video/blog/" }, + { label: "FAQ", href: "https://about.divine.video/faqs/" }, + { label: "In the News", href: "https://about.divine.video/news/" }, + { label: t("menu.merch"), to: "/merch" }, + ]; + + const linkClass = + "text-sm font-medium text-brand-off-white hover:text-brand-green transition-colors"; + + const renderNavLink = (item: NavItem, className: string, onClick?: () => void) => + item.to ? ( + + {item.label} + + ) : ( + + {item.label} + + ); return (