Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/lib/components/AppwriteIn100Seconds.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
100 changes: 84 additions & 16 deletions src/lib/components/homepage-variations/custom-hero.svelte
Original file line number Diff line number Diff line change
@@ -1,27 +1,52 @@
<script lang="ts">
import { building } from '$app/environment';
import { page } from '$app/state';
import { PUBLIC_APPWRITE_DASHBOARD } from '$env/static/public';
import { trackEvent } from '$lib/actions/analytics';
import { DEFAULT_HERO_SUBTITLE, DEFAULT_HERO_TITLE } from '$lib/statsig/constants';
import { resolveHeroQueryOverrides } from '$lib/statsig/hero-query-overrides';
import type { HeroLayoutVariant } from '$lib/statsig/hero-layout';
import GradientText from '$lib/components/fancy/gradient-text.svelte';
import { Button } from '$lib/components/ui';
import { cn } from '$lib/utils/cn';
import Dashboard from '$routes/(marketing)/(components)/dashboard.svelte';

const {
title = 'Build like a team of hundreds',
subtitle = 'Appwrite is an open-source, all-in-one development platform. Use built-in backend infrastructure and web hosting, all from a single place.',
title = DEFAULT_HERO_TITLE,
subtitle = DEFAULT_HERO_SUBTITLE,
showDashboard = true,
ctaLabel = 'Start building for free',
ctaHref = PUBLIC_APPWRITE_DASHBOARD
ctaHref = PUBLIC_APPWRITE_DASHBOARD,
heroLayout = 0
} = $props<{
title?: string;
subtitle?: string;
showDashboard?: boolean;
ctaLabel?: string;
ctaHref?: string;
heroLayout?: HeroLayoutVariant;
}>();

const resolved = $derived(
resolveHeroQueryOverrides(building ? new URLSearchParams() : page.url.searchParams, {
heroLayout,
heroSubtitle: subtitle,
heroTitle: title
})
);

const layoutAside = $derived(resolved.heroLayout === 0);
const layoutBottomTwoLineTitle = $derived(resolved.heroLayout === 1);
</script>

<div class="relative flex max-w-screen items-center overflow-hidden py-12 md:py-0 lg:min-h-[700px]">
<div
class={cn(
'relative flex max-w-screen items-center overflow-hidden',
layoutAside
? 'py-12 md:py-0 lg:min-h-[700px]'
: 'pt-16 pb-8 md:pt-20 md:pb-10 lg:min-h-[600px] lg:pt-24'
)}
>
<div
class={cn(
'animate-lighting absolute top-0 left-0 -z-10 h-screen w-[200vw] -translate-x-[25%] translate-y-8 rotate-25 overflow-hidden blur-3xl md:w-full',
Expand All @@ -31,25 +56,62 @@
></div>

<div
class="relative container mx-auto grid h-full grid-cols-1 place-items-center gap-24 md:grid-cols-2"
class={cn(
'relative container mx-auto h-full',
layoutAside
? 'grid grid-cols-1 place-items-center gap-24 md:grid-cols-2'
: 'flex w-full flex-col items-center gap-16 md:gap-20 lg:gap-24'
)}
>
<div
class="animate-blur-in flex flex-col gap-4 [animation-delay:150ms] [animation-duration:1000ms] md:ml-12 lg:ml-0"
class={cn(
'animate-blur-in flex flex-col [animation-delay:150ms] [animation-duration:1000ms]',
layoutAside
? 'gap-4 md:ml-12 lg:ml-0'
: 'w-full max-w-6xl items-center gap-3 px-4 text-center sm:px-0'
)}
>
<GradientText class="animate-fade-in">
<h1 class="font-aeonik-pro text-headline text-pretty">
{title}<span class="text-accent">_</span>
</h1>
</GradientText>
{#if layoutAside || layoutBottomTwoLineTitle}
<GradientText
class={cn(
'animate-fade-in my-2 md:my-3',
layoutBottomTwoLineTitle && 'mx-auto block w-full max-w-4xl text-center'
)}
>
<h1 class="font-aeonik-pro text-headline text-pretty">
{resolved.heroTitle}<span class="text-accent">_</span>
</h1>
</GradientText>
{:else}
<GradientText
class="animate-fade-in my-2 flex w-full max-w-full min-w-0 justify-center overflow-x-auto [-webkit-overflow-scrolling:touch] [scrollbar-width:none] md:my-3 [&::-webkit-scrollbar]:hidden"
>
<h1 class="font-aeonik-pro text-headline max-w-none shrink-0 whitespace-nowrap">
{resolved.heroTitle}<span class="text-accent">_</span>
</h1>
</GradientText>
{/if}

<p class="text-description text-secondary font-medium">
{subtitle}
<p
class={cn(
'text-description text-secondary mt-2 font-medium md:mt-3',
!layoutAside && 'max-w-2xl text-center text-balance'
)}
>
{resolved.heroSubtitle}
</p>

<div class="mt-4 flex flex-col gap-2 lg:flex-row">
<div
class={cn(
'flex flex-col gap-2',
layoutAside
? 'mt-4 lg:flex-row'
: 'mt-3 w-full max-w-xs items-stretch sm:max-w-none sm:flex-row sm:justify-center'
)}
>
<Button
href={ctaHref}
class="w-full! lg:w-fit!"
class={layoutAside ? 'w-full! lg:w-fit!' : 'w-full! sm:w-fit!'}
onclick={() => {
trackEvent(`main-get_started_btn_hero-click`);
}}>{ctaLabel}</Button
Expand All @@ -58,7 +120,13 @@
</div>

{#if showDashboard}
<Dashboard />
{#if layoutAside}
<Dashboard />
{:else}
<div class="flex w-full justify-center">
<Dashboard placement="below" />
</div>
{/if}
{/if}
</div>
</div>
4 changes: 4 additions & 0 deletions src/lib/components/homepage-variations/variation-config.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
7 changes: 5 additions & 2 deletions src/lib/statsig/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>;
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<string, string>): void;
shutdown(): Promise<void>;
};
Expand Down
5 changes: 5 additions & 0 deletions src/lib/statsig/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
13 changes: 13 additions & 0 deletions src/lib/statsig/hero-layout.ts
Original file line number Diff line number Diff line change
@@ -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;
}
67 changes: 67 additions & 0 deletions src/lib/statsig/hero-query-overrides.ts
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +1 to +10
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Content injection phishing vector on production

?hero_title and ?hero_subtitle are processed server-side in +page.server.ts via resolveHeroQueryOverrides(url.searchParams, ...) and baked into SSR HTML. A shareable URL like https://appwrite.io/?hero_title=Your+account+has+been+compromised.+Reset+your+password+now will render arbitrary attacker-controlled text as the <h1> on Appwrite's branded homepage — a classic URL-reflection phishing vector. The MAX_TITLE_LEN = 160 / MAX_SUBTITLE_LEN = 560 limits still allow ample deceptive copy.

Consider gating hero_title and hero_subtitle query overrides to non-production environments (e.g. !building && ENV.PREVIEW) on the server load, while keeping hero_layout open if visual QA on production is intentional.


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;
}
32 changes: 31 additions & 1 deletion src/lib/statsig/hero-statsig.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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<HeroLayoutVariant> {
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`.
Expand Down
10 changes: 8 additions & 2 deletions src/routes/(marketing)/(components)/dashboard.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
<script lang="ts">
import { cn } from '$lib/utils/cn';

type Placement = 'aside' | 'below';

const { placement = 'aside' }: { placement?: Placement } = $props();
// import { animate, stagger } from 'motion';

// $effect(() => {
Expand All @@ -21,8 +25,10 @@

<div
class={cn(
'bg-smooth -mb-108 max-w-[150vw] translate-x-8 -translate-y-32 scale-70 overflow-hidden rounded-t-2xl border-x border-t border-white/10 px-2 pt-2 backdrop-blur-2xl md:mt-12 md:mb-0 md:ml-24 md:translate-x-1/4 md:translate-y-0 md:scale-100 lg:ml-12 ',
'mask-b-from-0% mask-b-to-70% md:mask-b-to-100%'
placement === 'aside' &&
'bg-smooth -mb-108 max-w-[150vw] translate-x-8 -translate-y-32 scale-70 overflow-hidden rounded-t-2xl border-x border-t border-white/10 mask-b-from-0% mask-b-to-70% px-2 pt-2 backdrop-blur-2xl md:mt-12 md:mb-0 md:ml-24 md:translate-x-1/4 md:translate-y-0 md:scale-100 md:mask-b-to-100% lg:ml-12',
placement === 'below' &&
'bg-smooth mx-auto -mb-8 max-h-[min(52vw,272px)] w-full max-w-[min(1185px,calc(100vw-2rem))] scale-[0.72] overflow-hidden rounded-t-2xl border-x border-t border-white/10 mask-b-from-[48%] mask-b-to-[100%] px-2 pt-2 backdrop-blur-2xl sm:max-h-[min(48vw,292px)] sm:scale-90 md:mb-0 md:max-h-[min(42vw,332px)] md:scale-100 md:mask-b-from-[52%]'
)}
>
<div class="bg-greyscale-900 h-full overflow-hidden rounded-t-xl">
Expand Down
Loading
Loading