diff --git a/src/lib/components/AppwriteIn100Seconds.svelte b/src/lib/components/AppwriteIn100Seconds.svelte index 82774ad90a..3595520784 100644 --- a/src/lib/components/AppwriteIn100Seconds.svelte +++ b/src/lib/components/AppwriteIn100Seconds.svelte @@ -15,7 +15,7 @@ action={trigger} event="intro-video-btn_hero-click" variant="secondary" - class="w-full! cursor-pointer shadow-[0_2px_40px_rgba(0,0,0,0.5)] transition-opacity hover:opacity-90 active:scale-95 lg:w-fit!" + class="w-full! cursor-pointer shadow-[0_2px_40px_rgba(0,0,0,0.5)] transition-opacity hover:opacity-90 active:scale-95 sm:w-fit!" > Appwrite in 100 seconds diff --git a/src/lib/components/homepage-variations/custom-hero.svelte b/src/lib/components/homepage-variations/custom-hero.svelte index 45e0717040..a29531ab7b 100644 --- a/src/lib/components/homepage-variations/custom-hero.svelte +++ b/src/lib/components/homepage-variations/custom-hero.svelte @@ -1,27 +1,52 @@ -
+
- -

- {title}_ -

-
+ {#if layoutAside || layoutBottomTwoLineTitle} + +

+ {resolved.heroTitle}_ +

+
+ {:else} + +

+ {resolved.heroTitle}_ +

+
+ {/if} -

- {subtitle} +

+ {resolved.heroSubtitle}

-
+
{#if showDashboard} - + {#if layoutAside} + + {:else} +
+ +
+ {/if} {/if}
diff --git a/src/lib/components/homepage-variations/variation-config.ts b/src/lib/components/homepage-variations/variation-config.ts index 95581a34c5..c5378bb45d 100644 --- a/src/lib/components/homepage-variations/variation-config.ts +++ b/src/lib/components/homepage-variations/variation-config.ts @@ -1,7 +1,11 @@ +import type { HeroLayoutVariant } from '$lib/statsig/hero-layout'; + export interface HomepageVariationConfig { title: string; subtitle: string; pageTitle: string; + /** Hero structure: `0` aside dashboard, `1` bottom + wrapped title, `2` bottom + single-line title. */ + heroLayout?: HeroLayoutVariant; slug?: string; ctaLabel?: string; ctaHref?: string; diff --git a/src/lib/statsig/client.ts b/src/lib/statsig/client.ts index c16bcd5e71..7b467ece0c 100644 --- a/src/lib/statsig/client.ts +++ b/src/lib/statsig/client.ts @@ -3,16 +3,19 @@ import { ENV } from '$lib/system'; import { STATSIG_STABLE_ID_KEY, STATSIG_EXPERIMENT_BEST_DESCRIPTION, + STATSIG_EXPERIMENT_HERO_LAYOUT, STATSIG_CLIENT_SDK_KEY } from '$lib/statsig/constants'; -export { STATSIG_EXPERIMENT_BEST_DESCRIPTION }; +export { STATSIG_EXPERIMENT_BEST_DESCRIPTION, STATSIG_EXPERIMENT_HERO_LAYOUT }; /** Narrow surface we use from `@statsig/js-client` (avoids static `import type` from that package, which can break Vite named re-exports). */ export type StatsigBrowserClient = { initializeSync(options?: object): unknown; initializeAsync(options?: object): Promise; - getExperiment(name: string): { get(key: string, defaultValue: string): string }; + getExperiment(name: string): { + get(key: string, defaultValue: string | number): string | number; + }; logEvent(eventOrName: string, value?: string | number, metadata?: Record): void; shutdown(): Promise; }; diff --git a/src/lib/statsig/constants.ts b/src/lib/statsig/constants.ts index 40c5d82670..ffb8834ba0 100644 --- a/src/lib/statsig/constants.ts +++ b/src/lib/statsig/constants.ts @@ -6,5 +6,10 @@ export const STATSIG_CLIENT_SDK_KEY = 'client-TRp4ODQ3Yfsha0XwmRayqwb7WW0ujUbiGr export const STATSIG_EXPERIMENT_BEST_DESCRIPTION = 'best_description'; +/** Experiment param key: `layout` (values `0` | `1` | `2`). */ +export const STATSIG_EXPERIMENT_HERO_LAYOUT = 'hero_layout'; + export const DEFAULT_HERO_SUBTITLE = 'Appwrite is an open-source, all-in-one development platform. Use built-in backend infrastructure and web hosting, all from a single place.'; + +export const DEFAULT_HERO_TITLE = 'Build like a team of hundreds'; diff --git a/src/lib/statsig/hero-layout.ts b/src/lib/statsig/hero-layout.ts new file mode 100644 index 0000000000..30a1d8e574 --- /dev/null +++ b/src/lib/statsig/hero-layout.ts @@ -0,0 +1,13 @@ +/** `hero_layout` experiment: `0` aside dashboard, `1` bottom + multi-line title, `2` bottom + single-line title. */ +export type HeroLayoutVariant = 0 | 1 | 2; + +export function normalizeHeroLayout(raw: unknown, fallback: HeroLayoutVariant): HeroLayoutVariant { + if (typeof raw === 'number' && (raw === 0 || raw === 1 || raw === 2)) { + return raw; + } + const n = parseInt(String(raw).trim(), 10); + if (n === 0 || n === 1 || n === 2) { + return n; + } + return fallback; +} diff --git a/src/lib/statsig/hero-query-overrides.ts b/src/lib/statsig/hero-query-overrides.ts new file mode 100644 index 0000000000..62741ffde6 --- /dev/null +++ b/src/lib/statsig/hero-query-overrides.ts @@ -0,0 +1,67 @@ +import { normalizeHeroLayout, type HeroLayoutVariant } from './hero-layout'; + +/** Query overrides for marketing hero experiments (Statsig parity for local QA). */ + +export const HERO_LAYOUT_QUERY_KEY = 'hero_layout'; +export const HERO_SUBTITLE_QUERY_KEY = 'hero_subtitle'; +export const HERO_TITLE_QUERY_KEY = 'hero_title'; + +const MAX_SUBTITLE_LEN = 560; +const MAX_TITLE_LEN = 160; + +export type HeroQueryBaseline = { + heroLayout: HeroLayoutVariant; + heroSubtitle: string; + heroTitle: string; +}; + +export type HeroQueryResolved = HeroQueryBaseline; + +export function hasHeroExperimentQueryOverrides(params: URLSearchParams): boolean { + return ( + params.has(HERO_LAYOUT_QUERY_KEY) || + params.has(HERO_SUBTITLE_QUERY_KEY) || + params.has(HERO_TITLE_QUERY_KEY) + ); +} + +/** + * Applies `hero_layout`, `hero_subtitle`, and `hero_title` search params when present. + * Values are clamped; unknown `hero_layout` values keep the baseline layout. + */ +export function resolveHeroQueryOverrides( + params: URLSearchParams, + baseline: HeroQueryBaseline +): HeroQueryResolved { + return { + heroLayout: readHeroLayoutOverride(params, baseline.heroLayout), + heroSubtitle: readHeroSubtitleOverride(params, baseline.heroSubtitle), + heroTitle: readHeroTitleOverride(params, baseline.heroTitle) + }; +} + +function readHeroLayoutOverride( + params: URLSearchParams, + fallback: HeroLayoutVariant +): HeroLayoutVariant { + if (!params.has(HERO_LAYOUT_QUERY_KEY)) return fallback; + return normalizeHeroLayout(params.get(HERO_LAYOUT_QUERY_KEY), fallback); +} + +function readHeroSubtitleOverride(params: URLSearchParams, fallback: string): string { + if (!params.has(HERO_SUBTITLE_QUERY_KEY)) return fallback; + const raw = params.get(HERO_SUBTITLE_QUERY_KEY); + if (raw == null) return fallback; + const t = raw.replace(/\s+/g, ' ').trim(); + if (t.length === 0) return fallback; + return t.length > MAX_SUBTITLE_LEN ? t.slice(0, MAX_SUBTITLE_LEN) : t; +} + +function readHeroTitleOverride(params: URLSearchParams, fallback: string): string { + if (!params.has(HERO_TITLE_QUERY_KEY)) return fallback; + const raw = params.get(HERO_TITLE_QUERY_KEY); + if (raw == null) return fallback; + const t = raw.replace(/\s+/g, ' ').trim(); + if (t.length === 0) return fallback; + return t.length > MAX_TITLE_LEN ? t.slice(0, MAX_TITLE_LEN) : t; +} diff --git a/src/lib/statsig/hero-statsig.server.ts b/src/lib/statsig/hero-statsig.server.ts index 1d1b263040..7e7cb3baff 100644 --- a/src/lib/statsig/hero-statsig.server.ts +++ b/src/lib/statsig/hero-statsig.server.ts @@ -5,7 +5,12 @@ import { type StatsigOptions, type StatsigUserArgs } from '@statsig/statsig-node-core'; -import { STATSIG_CLIENT_SDK_KEY, STATSIG_EXPERIMENT_BEST_DESCRIPTION } from './constants'; +import { + STATSIG_CLIENT_SDK_KEY, + STATSIG_EXPERIMENT_BEST_DESCRIPTION, + STATSIG_EXPERIMENT_HERO_LAYOUT +} from './constants'; +import { normalizeHeroLayout, type HeroLayoutVariant } from './hero-layout'; function buildStatsigServerOptions(): StatsigOptions { const explicit = env.STATSIG_ENVIRONMENT?.trim(); @@ -88,6 +93,31 @@ export async function evaluateHeroDescriptionExperiment( } } +/** + * Evaluates `hero_layout` for SSR. Exposure is logged on the client when the hero reads + * `getExperiment(STATSIG_EXPERIMENT_HERO_LAYOUT).get('layout', …)` after `whenStatsigReady()`. + * + * Configure a dynamic config / experiment parameter named `layout` with values `0`, `1`, or `2`. + */ +export async function evaluateHeroLayoutExperiment( + user: StatsigUser | StatsigServerUserInput, + fallback: HeroLayoutVariant +): Promise { + const client = await getStatsigClient(); + if (!client) return fallback; + + try { + const statsigUser = toStatsigUser(user); + const experiment = client.getExperiment(statsigUser, STATSIG_EXPERIMENT_HERO_LAYOUT, { + disableExposureLogging: true + }); + const raw = experiment.get('layout', fallback); + return normalizeHeroLayout(raw, fallback); + } catch { + return fallback; + } +} + /** * JSON payload for `StatsigClient.dataAdapter.setData` + `initializeAsync`, so the JS SDK skips * cache-first experiment checks (Group Assignment Health). Requires `STATSIG_SERVER_SECRET`. diff --git a/src/routes/(marketing)/(components)/dashboard.svelte b/src/routes/(marketing)/(components)/dashboard.svelte index 32da1d5b75..d06f4aa024 100644 --- a/src/routes/(marketing)/(components)/dashboard.svelte +++ b/src/routes/(marketing)/(components)/dashboard.svelte @@ -1,5 +1,9 @@ -
+
- + {#if layoutAside} + + {:else} +
+ +
+ {/if} - -

- {title}_ -

-
+ {#if layoutAside || layoutBottomTwoLineTitle} + +

+ {resolved.heroTitle}_ +

+
+ {:else} + +

+ {resolved.heroTitle}_ +

+
+ {/if}

- {subtitle} + {resolved.heroSubtitle}

-
+
- - + {/if}
diff --git a/src/routes/(marketing)/+page.server.ts b/src/routes/(marketing)/+page.server.ts index cb158aad1a..4f8009f105 100644 --- a/src/routes/(marketing)/+page.server.ts +++ b/src/routes/(marketing)/+page.server.ts @@ -1,6 +1,13 @@ -import { STATSIG_STABLE_ID_KEY, DEFAULT_HERO_SUBTITLE } from '$lib/statsig/constants'; +import { + STATSIG_STABLE_ID_KEY, + DEFAULT_HERO_SUBTITLE, + DEFAULT_HERO_TITLE +} from '$lib/statsig/constants'; +import { resolveHeroQueryOverrides } from '$lib/statsig/hero-query-overrides'; +import { building } from '$app/environment'; import { evaluateHeroDescriptionExperiment, + evaluateHeroLayoutExperiment, getStatsigClientBootstrapPayload } from '$lib/statsig/hero-statsig.server'; import type { PageServerLoad } from './$types'; @@ -26,13 +33,25 @@ export const load: PageServerLoad = async ({ cookies, request, url }) => { ...(userAgent ? { userAgent } : {}) }; - const [heroSubtitle, statsigBootstrap] = await Promise.all([ + const [heroSubtitleBase, heroLayoutBase, statsigBootstrap] = await Promise.all([ evaluateHeroDescriptionExperiment(user, DEFAULT_HERO_SUBTITLE), + evaluateHeroLayoutExperiment(user, 0), getStatsigClientBootstrapPayload(user) ]); + // `url.searchParams` is unavailable while prerendering (`+page.ts` has `prerender = true`). + // Query overrides still apply in the browser via `hero.svelte` + `page.url.searchParams`. + const heroQueryParams = building ? new URLSearchParams() : url.searchParams; + const { heroSubtitle, heroLayout, heroTitle } = resolveHeroQueryOverrides(heroQueryParams, { + heroLayout: heroLayoutBase, + heroSubtitle: heroSubtitleBase, + heroTitle: DEFAULT_HERO_TITLE + }); + return { heroSubtitle, + heroLayout, + heroTitle, statsigBootstrap, /** Same value as `STATSIG_STABLE_ID_KEY` cookie — pass to client init to avoid bootstrap / stableID mismatch. */ statsigStableUserId: stableId, diff --git a/src/routes/(marketing)/+page.svelte b/src/routes/(marketing)/+page.svelte index f08e9b0ac6..7c951dda7c 100644 --- a/src/routes/(marketing)/+page.svelte +++ b/src/routes/(marketing)/+page.svelte @@ -23,7 +23,7 @@ />
- + diff --git a/src/routes/[variation]/+page.svelte b/src/routes/[variation]/+page.svelte index 23198858b2..95b37b1ef2 100644 --- a/src/routes/[variation]/+page.svelte +++ b/src/routes/[variation]/+page.svelte @@ -27,6 +27,7 @@ showDashboard={config.showDashboard} ctaLabel={config.ctaLabel} ctaHref={config.ctaHref} + heroLayout={config.heroLayout ?? 0} /> {#if config.showPlatforms}