diff --git a/compute-js/src/index.js b/compute-js/src/index.js index fd4a4ce1..70a0c5c1 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 00000000..247d3d23 --- /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/index.html b/index.html index 948872c0..af108e5c 100644 --- a/index.html +++ b/index.html @@ -15,8 +15,13 @@ with self-referencing canonicals at build time. --> - Divine Web - Short-form Looping Videos on Nostr - + + Divine — short video on an open protocol + @@ -30,18 +35,18 @@ - - + + - + - - + + diff --git a/package.json b/package.json index 441c60dd..1050e71a 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,12 @@ "deploy": "npm run build && npx -y nostr-deploy-cli deploy --skip-setup", "deploy:cloudflare": "npm run build && wrangler pages deploy dist", "deploy:cloudflare:preview": "npm run build && wrangler pages deploy dist --branch preview", - "fastly:local": "npm run build && npm i && npm run -w divine-web-edge dev:publish && npm run -w divine-web-edge dev:start", - "fastly:deploy": "npm run build && npm i && npm run -w divine-web-edge fastly:deploy", - "fastly:publish": "npm run build && npm i && npm run -w divine-web-edge fastly:publish", + "fastly:local": "npm run verify:web-mode-sync && npm run build && npm i && npm run -w divine-web-edge dev:publish && npm run -w divine-web-edge dev:start", + "fastly:deploy": "npm run verify:web-mode-sync && npm run build && npm i && npm run -w divine-web-edge fastly:deploy", + "fastly:publish": "npm run verify:web-mode-sync && npm run build && npm i && npm run -w divine-web-edge fastly:publish", "fastly:release": "npm run fastly:deploy && npm run fastly:publish", "verify:well-known": "node scripts/verify-well-known.mjs", + "verify:web-mode-sync": "node scripts/verify-web-mode-sync.mjs", "precalculate-thumbnails": "tsx scripts/precalculate-hashtag-thumbnails.ts", "generate-icons": "node scripts/generate-icons.js", "test:visual": "playwright test", diff --git a/playwright.config.ts b/playwright.config.ts index 4857c2e5..e40fcadf 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -3,6 +3,8 @@ import { defineConfig, devices } from '@playwright/test'; // Use a dedicated port so parallel worktrees on 8080 don't collide. const PORT = Number(process.env.PLAYWRIGHT_PORT ?? 8088); const BASE_URL = `http://localhost:${PORT}`; +const FULL_MODE_PORT = Number(process.env.PLAYWRIGHT_FULL_MODE_PORT ?? 8089); +const FULL_MODE_BASE_URL = `http://localhost:${FULL_MODE_PORT}`; export default defineConfig({ testDir: './tests/visual', @@ -14,11 +16,26 @@ export default defineConfig({ baseURL: BASE_URL, trace: 'on-first-retry', }, - projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], - webServer: { - command: `npx vite --port ${PORT} --strictPort`, - url: BASE_URL, - reuseExistingServer: !process.env.CI, - timeout: 120_000, - }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + { + name: 'full-mode-a11y', + testMatch: /a11y\.spec\.ts/, + use: { ...devices['Desktop Chrome'], baseURL: FULL_MODE_BASE_URL }, + }, + ], + webServer: [ + { + command: `npx vite --port ${PORT} --strictPort`, + url: BASE_URL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + { + command: `VITE_WEB_MODE=full npx vite --port ${FULL_MODE_PORT} --strictPort`, + url: FULL_MODE_BASE_URL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + ], }); diff --git a/public/store-badges/zapstore-badge.svg b/public/store-badges/zapstore-badge.svg new file mode 100644 index 00000000..4a18a966 --- /dev/null +++ b/public/store-badges/zapstore-badge.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/scripts/verify-live-bundle.mjs b/scripts/verify-live-bundle.mjs index d0046c1c..3b637d4e 100644 --- a/scripts/verify-live-bundle.mjs +++ b/scripts/verify-live-bundle.mjs @@ -26,6 +26,7 @@ const CURL_STATUS_MARKER = '\n__HTTP_STATUS__:'; // so a compressed-only 500 (#489) sails through a green deploy. verifyInjectedRoutesOk uses // this to assert the injected routes actually serve browsers. const BROWSER_ACCEPT_ENCODING = 'gzip, deflate, br'; +const FULL_MODE_INJECTED_URLS = ['https://divine.video/', 'https://divine.video/discovery/classics']; function headerEntries(headers = {}) { if (typeof Headers !== 'undefined' && headers instanceof Headers) { @@ -92,6 +93,17 @@ export function extractEntryScript(html) { return match ? match[1] : null; } +export function resolveInjectedUrls({ env = process.env } = {}) { + if (env.VERIFY_INJECTED_URLS !== undefined) { + return env.VERIFY_INJECTED_URLS + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + } + + return env.VITE_WEB_MODE === 'full' ? FULL_MODE_INJECTED_URLS : []; +} + /** * Poll each live origin until it serves the expected entry bundle, or fail. * @@ -277,15 +289,11 @@ if (invokedDirectly) { const attempts = numberFromEnv('VERIFY_BUNDLE_ATTEMPTS', 18); const delayMs = numberFromEnv('VERIFY_BUNDLE_DELAY_MS', 20000); - // Edge-injected routes (apex landing + a /discovery tab) are the only ones that read and - // rewrite the HTML at the edge, so they are the only ones that can 500 on compressed input - // (#489). A deterministic 500 here won't self-heal, so use a short retry budget that only - // absorbs a cold-start blip rather than the KV-propagation window the bundle check needs. - const injectedUrls = (process.env.VERIFY_INJECTED_URLS - ?? 'https://divine.video/,https://divine.video/discovery/classics') - .split(',') - .map((value) => value.trim()) - .filter(Boolean); + // In full mode, edge-injected routes (apex landing + a /discovery tab) are the only + // ones that read and rewrite HTML at the edge, so they are the only ones that can + // 500 on compressed input (#489). Showcase mode deliberately disables that feed + // injection; VERIFY_INJECTED_URLS can still force a custom check when needed. + const injectedUrls = resolveInjectedUrls(); const injectedAttempts = numberFromEnv('VERIFY_INJECTED_ATTEMPTS', 3); const injectedDelayMs = numberFromEnv('VERIFY_INJECTED_DELAY_MS', 10000); @@ -300,14 +308,18 @@ if (invokedDirectly) { }); console.log(`✓ Live origins serve the freshly built bundle ${expected}`); - await verifyInjectedRoutesOk({ - urls: injectedUrls, - attempts: injectedAttempts, - delayMs: injectedDelayMs, - fetchImpl: fetchWithCurl, - log: (message) => console.log(message), - }); - console.log('✓ Injected routes return 2xx to a browser (Accept-Encoding: br)'); + if (injectedUrls.length > 0) { + await verifyInjectedRoutesOk({ + urls: injectedUrls, + attempts: injectedAttempts, + delayMs: injectedDelayMs, + fetchImpl: fetchWithCurl, + log: (message) => console.log(message), + }); + console.log('✓ Injected routes return 2xx to a browser (Accept-Encoding: br)'); + } else { + console.log('- Skipping injected-route check; showcase mode has no feed-injected routes'); + } } catch (err) { console.error(`✗ ${err.message}`); process.exit(1); diff --git a/scripts/verify-live-bundle.test.ts b/scripts/verify-live-bundle.test.ts index 25b6d18e..31215c11 100644 --- a/scripts/verify-live-bundle.test.ts +++ b/scripts/verify-live-bundle.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { buildCurlArgs, extractEntryScript, + resolveInjectedUrls, verifyLiveBundle, verifyInjectedRoutesOk, } from './verify-live-bundle.mjs'; @@ -336,6 +337,28 @@ describe('verifyInjectedRoutesOk', () => { }); }); +describe('resolveInjectedUrls', () => { + it('defaults to no injected routes in showcase mode', () => { + expect(resolveInjectedUrls({ env: { VITE_WEB_MODE: 'showcase' } })).toEqual([]); + }); + + it('defaults to the feed-injected routes in full mode', () => { + expect(resolveInjectedUrls({ env: { VITE_WEB_MODE: 'full' } })).toEqual([ + 'https://divine.video/', + 'https://divine.video/discovery/classics', + ]); + }); + + it('lets VERIFY_INJECTED_URLS override the mode default', () => { + expect(resolveInjectedUrls({ + env: { + VITE_WEB_MODE: 'showcase', + VERIFY_INJECTED_URLS: 'https://example.test/a, https://example.test/b', + }, + })).toEqual(['https://example.test/a', 'https://example.test/b']); + }); +}); + describe('buildCurlArgs', () => { it('emits request headers as -H flags so they actually reach curl', () => { const args = buildCurlArgs('https://divine.video/', { diff --git a/scripts/verify-og-tags.sh b/scripts/verify-og-tags.sh index 264c05d9..38735372 100755 --- a/scripts/verify-og-tags.sh +++ b/scripts/verify-og-tags.sh @@ -175,7 +175,11 @@ run_check() { og_title_not_brand) local og_title og_title=$(extract_meta "$body" "og:title") - if [[ "$og_title" == "Divine Web - Short-form Looping Videos on Nostr" ]]; then + # Both the current shell default and the one it replaced count as "no + # handler fired". Keeping the old string means this still catches a + # stale cached shell being served from the edge. + if [[ "$og_title" == "Divine — short video on an open protocol" ]] \ + || [[ "$og_title" == "Divine Web - Short-form Looping Videos on Nostr" ]]; then all_passed=false echo " FAIL: og:title is generic brand fallback (no per-route handler fired)" echo " got: ${og_title}" diff --git a/scripts/verify-web-mode-sync.mjs b/scripts/verify-web-mode-sync.mjs new file mode 100644 index 00000000..67352a52 --- /dev/null +++ b/scripts/verify-web-mode-sync.mjs @@ -0,0 +1,23 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const edgeModeFile = path.join(projectRoot, 'compute-js', 'src', 'webMode.js'); + +const effectiveViteMode = process.env.VITE_WEB_MODE === 'full' ? 'full' : 'showcase'; +const edgeSource = await readFile(edgeModeFile, 'utf8'); +const edgeMode = edgeSource.match(/export const WEB_MODE = ['"]([^'"]+)['"];/)?.[1]; + +if (edgeMode !== 'showcase' && edgeMode !== 'full') { + console.error(`✗ Could not read a valid WEB_MODE from ${edgeModeFile}`); + process.exit(1); +} + +if (edgeMode !== effectiveViteMode) { + console.error(`✗ VITE_WEB_MODE resolves to "${effectiveViteMode}" but compute-js/src/webMode.js exports "${edgeMode}"`); + console.error(' Keep the frontend and Fastly worker modes in sync before deploying.'); + process.exit(1); +} + +console.log(`✓ Frontend and Fastly worker web mode are both "${edgeMode}"`); diff --git a/src/AppRouter.test.tsx b/src/AppRouter.test.tsx index bb58e14f..8f1a3903 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 a539aed4..5efe6b99 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 00000000..cb5b0d49 --- /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 00000000..244e8584 --- /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 00000000..3e21ebf1 --- /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 d2c2f4b4..6e1ec05b 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 (