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. */}
+