feat: Ship editor chooser testB and use live_2026 UTM campaign
End the A/B test by always showing the testB modal, remove unused variant components and assignment logic, and route CTA links with utm_campaign=live_2026. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,118 +0,0 @@
|
||||
<script lang="ts">
|
||||
import McWrapper from '$/components/McWrapper.svelte';
|
||||
import PrivacyPolicyLink from '$/components/migration/PrivacyPolicyLink.svelte';
|
||||
import { Button } from '$/components/ui/button';
|
||||
import * as Dialog from '$/components/ui/dialog';
|
||||
import CodeIcon from '~icons/custom/code';
|
||||
import ArrowIcon from '~icons/material-symbols/arrow-forward-rounded';
|
||||
import OpenSourceIcon from '~icons/material-symbols/book-2-outline-rounded';
|
||||
import ChatIcon from '~icons/material-symbols/chat-outline-rounded';
|
||||
import EditIcon from '~icons/material-symbols/edit-outline-rounded';
|
||||
import HistoryIcon from '~icons/material-symbols/history';
|
||||
import HomeIcon from '~icons/material-symbols/home-storage-outline-rounded';
|
||||
import SparklesIcon from '~icons/material-symbols/kid-star-outline';
|
||||
import LanguageIcon from '~icons/material-symbols/language';
|
||||
import WidthIcon from '~icons/material-symbols/width-rounded';
|
||||
import type { EditorChooserVariantProps } from './editorChooserActions';
|
||||
|
||||
let { actions }: EditorChooserVariantProps = $props();
|
||||
|
||||
const plusFeatures = [
|
||||
{ icon: EditIcon, label: 'Visual editor' },
|
||||
{ icon: SparklesIcon, label: '300 AI credits' },
|
||||
{ icon: HomeIcon, label: 'Unlimited diagram storage' },
|
||||
{ icon: WidthIcon, label: 'Limitless diagram size' },
|
||||
{ icon: ChatIcon, label: 'View & comment collaboration' }
|
||||
];
|
||||
const openSourceFeatures = [
|
||||
{ icon: LanguageIcon, label: 'Diagram stored in URL' },
|
||||
{ icon: CodeIcon, label: 'Code editor' },
|
||||
{ icon: OpenSourceIcon, label: 'Open source' },
|
||||
{ icon: HistoryIcon, label: 'Version history' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<Dialog.Content class="flex max-w-2xl flex-col gap-6 bg-pink-50 p-6 dark:bg-background">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="text-center text-2xl font-semibold">Choose your editor</Dialog.Title>
|
||||
<Dialog.Description class="text-center text-sm font-light">
|
||||
You'll never see this again
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<!-- Mermaid Plus Card -->
|
||||
<div
|
||||
class="relative flex flex-col overflow-hidden rounded-xl border-2 border-accent bg-white shadow dark:bg-card">
|
||||
<div class="bg-accent px-6 py-2 text-center text-sm font-semibold text-accent-foreground">
|
||||
Recommended
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 p-6">
|
||||
<div>
|
||||
<h3 class="text-xl font-bold">Mermaid Plus</h3>
|
||||
<p class="text-sm text-muted-foreground">Unlock AI, storage and collaboration</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex justify-center">
|
||||
<span
|
||||
class="rounded-full bg-pink-100 px-3 py-0.5 text-xs font-semibold text-pink-700 dark:bg-pink-950 dark:text-pink-300">
|
||||
10% off with code JS26
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<McWrapper labelPrefix="Opens ">
|
||||
<Button variant="accent" class="w-full" onclick={() => actions.startTrial()}>
|
||||
Start free trial
|
||||
</Button>
|
||||
</McWrapper>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-3 text-sm">
|
||||
{#each plusFeatures as feature (feature.label)}
|
||||
<li class="flex items-center gap-2">
|
||||
<feature.icon class="size-4 shrink-0 text-muted-foreground" />
|
||||
{feature.label}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Open Source Card -->
|
||||
<div class="flex flex-col gap-4 rounded-xl border bg-white p-6 shadow dark:bg-card">
|
||||
<div class="flex flex-col justify-end pt-10">
|
||||
<h3 class="text-xl font-bold">Open Source</h3>
|
||||
<p class="text-sm text-muted-foreground">Code only, no login, always free</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="mt-2 text-sm text-muted-foreground">Mermaid has a new home</p>
|
||||
<McWrapper labelPrefix="Opens ">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="w-full border-accent"
|
||||
onclick={() => actions.openMermaidAiLive('continueToNewHome')}>
|
||||
Continue to mermaid.ai/live
|
||||
<ArrowIcon class="ml-1 size-4" />
|
||||
</Button>
|
||||
</McWrapper>
|
||||
<Button variant="outline" class="w-full" onclick={() => actions.dismiss('stayOnLive')}>
|
||||
Stay on mermaid.live
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-3 text-sm">
|
||||
{#each openSourceFeatures as feature (feature.label)}
|
||||
<li class="flex items-center gap-2">
|
||||
<feature.icon class="size-4 shrink-0 text-muted-foreground" />
|
||||
{feature.label}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PrivacyPolicyLink />
|
||||
</Dialog.Content>
|
||||
@@ -1,10 +1,7 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$/components/ui/dialog';
|
||||
import { dismissEditorChooser, getEditorChooserVariant } from '$/util/migration/domainMigration';
|
||||
import EditorChooserControl from './EditorChooserControl.svelte';
|
||||
import EditorChooserTestA from './EditorChooserTestA.svelte';
|
||||
import { dismissEditorChooser } from '$/util/migration/domainMigration';
|
||||
import EditorChooserTestB from './EditorChooserTestB.svelte';
|
||||
import EditorChooserTestC from './EditorChooserTestC.svelte';
|
||||
import { createEditorChooserActions } from './editorChooserActions';
|
||||
|
||||
interface Props {
|
||||
@@ -12,11 +9,10 @@
|
||||
}
|
||||
|
||||
let { open = $bindable() }: Props = $props();
|
||||
const variant = getEditorChooserVariant();
|
||||
// Tracks whether the current close was triggered by an explicit action
|
||||
// (button click logs its own event) vs. implicit dismissal (ESC/click-outside).
|
||||
let handled = false;
|
||||
const actions = createEditorChooserActions(variant, () => {
|
||||
const actions = createEditorChooserActions(() => {
|
||||
handled = true;
|
||||
open = false;
|
||||
});
|
||||
@@ -30,13 +26,5 @@
|
||||
handled = false;
|
||||
dismissEditorChooser();
|
||||
}}>
|
||||
{#if variant === 'testA'}
|
||||
<EditorChooserTestA {actions} />
|
||||
{:else if variant === 'testB'}
|
||||
<EditorChooserTestB {actions} />
|
||||
{:else if variant === 'testC'}
|
||||
<EditorChooserTestC {actions} />
|
||||
{:else}
|
||||
<EditorChooserControl {actions} />
|
||||
{/if}
|
||||
<EditorChooserTestB {actions} />
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
<script lang="ts">
|
||||
import McWrapper from '$/components/McWrapper.svelte';
|
||||
import PrivacyPolicyLink from '$/components/migration/PrivacyPolicyLink.svelte';
|
||||
import { Button } from '$/components/ui/button';
|
||||
import * as Dialog from '$/components/ui/dialog';
|
||||
import { asset } from '$app/paths';
|
||||
import type { EditorChooserVariantProps } from './editorChooserActions';
|
||||
|
||||
let { actions }: EditorChooserVariantProps = $props();
|
||||
|
||||
const openSourceFeatures = [
|
||||
'Diagram stored in URL',
|
||||
'Code editor',
|
||||
'Open source',
|
||||
'Version history'
|
||||
];
|
||||
const plusFeatures = [
|
||||
'AI diagramming',
|
||||
'Visual editor',
|
||||
'Encrypted diagram storage',
|
||||
'Premium plugins',
|
||||
'View & comment collaboration'
|
||||
];
|
||||
</script>
|
||||
|
||||
<Dialog.Content class="flex max-w-3xl flex-col gap-6 bg-background p-8">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="text-center text-2xl font-bold">Choose your editor</Dialog.Title>
|
||||
<Dialog.Description class="sr-only">
|
||||
Choose between the open source editor or Mermaid Plus
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<!-- Open Source Card -->
|
||||
<div class="flex flex-col gap-4 rounded-xl border bg-card p-6">
|
||||
<div class="flex items-center">
|
||||
<img class="size-10" src={asset('/favicon.svg')} alt="Mermaid Live Editor" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-bold">Open source</h3>
|
||||
<p class="text-sm text-muted-foreground">Code only, no login, always free</p>
|
||||
</div>
|
||||
|
||||
<ul class="flex flex-col gap-2 text-sm">
|
||||
{#each openSourceFeatures as feature (feature)}
|
||||
<li class="flex items-center gap-3 rounded-lg border border-border p-3">
|
||||
<span class="size-2 shrink-0 rounded-full bg-blue-500"></span>
|
||||
{feature}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
class="mt-auto self-start"
|
||||
onclick={() => actions.dismiss('continueToLive')}>
|
||||
Continue to mermaid.live
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Mermaid Plus Card -->
|
||||
<div class="flex flex-col gap-4 rounded-xl border bg-card p-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<img class="size-10" src={asset('/mermaidchart-logo.svg')} alt="Mermaid Chart" />
|
||||
<span
|
||||
class="rounded-md bg-secondary px-2 py-0.5 text-xs font-semibold text-secondary-foreground">
|
||||
Recommended
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-bold">Mermaid Plus</h3>
|
||||
<p class="text-sm text-muted-foreground">Unlock AI, storage and collaboration</p>
|
||||
</div>
|
||||
|
||||
<ul class="flex flex-col gap-2 text-sm">
|
||||
{#each plusFeatures as feature (feature)}
|
||||
<li class="flex items-center gap-3 rounded-lg border border-border p-3">
|
||||
<span class="size-2 shrink-0 rounded-full bg-blue-500"></span>
|
||||
{feature}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<McWrapper labelPrefix="Opens ">
|
||||
<Button variant="accent" class="mt-auto self-start" onclick={() => actions.startTrial()}>
|
||||
Start free trial
|
||||
</Button>
|
||||
</McWrapper>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PrivacyPolicyLink />
|
||||
</Dialog.Content>
|
||||
@@ -9,9 +9,9 @@
|
||||
import AmazonIcon from '~icons/logos/aws';
|
||||
import GoogleIcon from '~icons/logos/google';
|
||||
import MicrosoftIcon from '~icons/logos/microsoft';
|
||||
import type { EditorChooserVariantProps } from './editorChooserActions';
|
||||
import type { EditorChooserProps } from './editorChooserActions';
|
||||
|
||||
let { actions }: EditorChooserVariantProps = $props();
|
||||
let { actions }: EditorChooserProps = $props();
|
||||
|
||||
const features = [
|
||||
{ title: 'AI diagram generation', description: 'Describe what you need, AI builds it' },
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
<script lang="ts">
|
||||
import McWrapper from '$/components/McWrapper.svelte';
|
||||
import PrivacyPolicyLink from '$/components/migration/PrivacyPolicyLink.svelte';
|
||||
import * as Dialog from '$/components/ui/dialog';
|
||||
import { asset } from '$app/paths';
|
||||
import type { Component } from 'svelte';
|
||||
import SparklesIcon from '~icons/hugeicons/sparkles';
|
||||
import ChevronIcon from '~icons/material-symbols/chevron-right-rounded';
|
||||
import CodeIcon from '~icons/material-symbols/code-rounded';
|
||||
import EditIcon from '~icons/material-symbols/rebase-edit-outline-rounded';
|
||||
import type { EditorChooserVariantProps } from './editorChooserActions';
|
||||
|
||||
let { actions }: EditorChooserVariantProps = $props();
|
||||
|
||||
interface Option {
|
||||
buttonClick: string;
|
||||
badge: string;
|
||||
description: string;
|
||||
icon: Component;
|
||||
onclick: () => void;
|
||||
title: string;
|
||||
external: boolean;
|
||||
}
|
||||
|
||||
const options: Option[] = [
|
||||
{
|
||||
badge: 'Start free trial',
|
||||
buttonClick: 'createWithAi',
|
||||
description: 'Describe your diagram in plain text and AI generates it for you',
|
||||
external: true,
|
||||
icon: SparklesIcon,
|
||||
onclick: () => actions.startTrial('createWithAi'),
|
||||
title: 'Create with AI'
|
||||
},
|
||||
{
|
||||
badge: 'Start free trial',
|
||||
buttonClick: 'useVisualEditor',
|
||||
description: 'Drag, drop, and connect shapes on an interactive canvas',
|
||||
external: true,
|
||||
icon: EditIcon,
|
||||
onclick: () => actions.startTrial('useVisualEditor'),
|
||||
title: 'Use visual editor'
|
||||
},
|
||||
{
|
||||
badge: 'Mermaid live',
|
||||
buttonClick: 'writeCode',
|
||||
description: 'Use Mermaid syntax directly in the code editor',
|
||||
external: false,
|
||||
icon: CodeIcon,
|
||||
onclick: () => actions.dismiss('writeCode'),
|
||||
title: 'Write code'
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<Dialog.Content class="flex max-w-xl flex-col gap-3 bg-background p-8">
|
||||
<Dialog.Header class="items-center gap-2 space-y-0">
|
||||
<img class="size-10" src={asset('/favicon.svg')} alt="Mermaid" />
|
||||
<Dialog.Title class="pt-2 text-2xl font-bold">How do you want to create?</Dialog.Title>
|
||||
<Dialog.Description class="text-xs text-muted-foreground">
|
||||
Choose how to start your diagram
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="mt-3 flex flex-col gap-3">
|
||||
{#snippet optionButton(option: Option)}
|
||||
<button
|
||||
type="button"
|
||||
class="group flex w-full items-center gap-3 rounded-lg border border-border px-3 py-3.5 text-left transition-colors hover:border-accent"
|
||||
onclick={option.onclick}>
|
||||
<option.icon class="size-5 shrink-0 text-muted-foreground group-hover:text-accent" />
|
||||
<div class="flex flex-1 flex-col gap-0.5">
|
||||
<span class="text-sm font-medium">{option.title}</span>
|
||||
<span class="text-xs font-light text-muted-foreground">{option.description}</span>
|
||||
</div>
|
||||
<span
|
||||
class="shrink-0 rounded-full bg-secondary px-3 py-1 text-xs font-normal text-secondary-foreground">
|
||||
{option.badge}
|
||||
</span>
|
||||
<ChevronIcon class="size-5 shrink-0 text-muted-foreground group-hover:text-accent" />
|
||||
</button>
|
||||
{/snippet}
|
||||
{#each options as option (option.buttonClick)}
|
||||
{#if option.external}
|
||||
<McWrapper labelPrefix="Opens ">
|
||||
{@render optionButton(option)}
|
||||
</McWrapper>
|
||||
{:else}
|
||||
{@render optionButton(option)}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class="mt-2 text-center text-xs text-muted-foreground">
|
||||
AI and visual editor are part of mermaid.ai. Start free today.
|
||||
</p>
|
||||
|
||||
<PrivacyPolicyLink />
|
||||
</Dialog.Content>
|
||||
@@ -1,23 +1,8 @@
|
||||
import type { EditorChooserVariant } from '$/util/experiments';
|
||||
import { logEvent, logMermaidChartClick } from '$/util/stats';
|
||||
import { getCheckoutUrl, getMermaidAiLiveUrl } from '$/util/util';
|
||||
|
||||
const utmMedium = 'editorSelection';
|
||||
|
||||
// See MC-4486: UTM campaign per variant for CTAs routing to mermaid.ai.
|
||||
// The campaign defaults to the variant name; TestC's two trial CTAs are disambiguated
|
||||
// by buttonClick so we can tell AI vs visual-editor clicks apart in analytics.
|
||||
const testCUtmCampaignByButtonClick: Record<string, string> = {
|
||||
createWithAi: 'testCAi',
|
||||
useVisualEditor: 'testCVisual'
|
||||
};
|
||||
|
||||
const resolveUtmCampaign = (variant: EditorChooserVariant, buttonClick: string): string => {
|
||||
if (variant === 'testC') {
|
||||
return testCUtmCampaignByButtonClick[buttonClick] ?? variant;
|
||||
}
|
||||
return variant;
|
||||
};
|
||||
const utmCampaign = 'live_2026';
|
||||
|
||||
export interface EditorChooserActions {
|
||||
log: (buttonClick: string) => void;
|
||||
@@ -26,30 +11,20 @@ export interface EditorChooserActions {
|
||||
openMermaidAiLive: (buttonClick: string) => void;
|
||||
}
|
||||
|
||||
export interface EditorChooserVariantProps {
|
||||
export interface EditorChooserProps {
|
||||
actions: EditorChooserActions;
|
||||
}
|
||||
|
||||
export const createEditorChooserActions = (
|
||||
variant: EditorChooserVariant,
|
||||
close: () => void
|
||||
): EditorChooserActions => {
|
||||
export const createEditorChooserActions = (close: () => void): EditorChooserActions => {
|
||||
const log = (buttonClick: string) => {
|
||||
logEvent('chooseEditor', { buttonClick, variant });
|
||||
logEvent('chooseEditor', { buttonClick });
|
||||
};
|
||||
|
||||
const startTrial = (buttonClick = 'startTrial') => {
|
||||
log(buttonClick);
|
||||
logMermaidChartClick('editorPicker');
|
||||
close();
|
||||
window.open(
|
||||
getCheckoutUrl({
|
||||
utmCampaign: resolveUtmCampaign(variant, buttonClick),
|
||||
utmMedium
|
||||
}),
|
||||
'_blank',
|
||||
'noopener'
|
||||
);
|
||||
window.open(getCheckoutUrl({ utmCampaign, utmMedium }), '_blank', 'noopener');
|
||||
};
|
||||
|
||||
const dismiss = (buttonClick: string) => {
|
||||
@@ -60,14 +35,7 @@ export const createEditorChooserActions = (
|
||||
const openMermaidAiLive = (buttonClick: string) => {
|
||||
log(buttonClick);
|
||||
close();
|
||||
window.open(
|
||||
getMermaidAiLiveUrl({
|
||||
utmCampaign: resolveUtmCampaign(variant, buttonClick),
|
||||
utmMedium
|
||||
}),
|
||||
'_blank',
|
||||
'noopener'
|
||||
);
|
||||
window.open(getMermaidAiLiveUrl({ utmCampaign, utmMedium }), '_blank', 'noopener');
|
||||
};
|
||||
|
||||
return { log, startTrial, dismiss, openMermaidAiLive };
|
||||
|
||||
@@ -12,6 +12,5 @@ export const TID = {
|
||||
export const C = {
|
||||
aiLiveEditor: 'ai_live_editor',
|
||||
editorChooserDismissedKey: 'mermaid-editor-chooser-dismissed',
|
||||
editorChooserVariantKey: 'mermaid-editor-chooser-variant',
|
||||
utmSource: 'mermaid_live_editor'
|
||||
} as const;
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export const editorChooserVariants = ['control', 'testA', 'testB', 'testC'] as const;
|
||||
export type EditorChooserVariant = (typeof editorChooserVariants)[number];
|
||||
@@ -1,6 +1,5 @@
|
||||
import { C } from '$/constants';
|
||||
import { env } from '$/util/env';
|
||||
import { editorChooserVariants, type EditorChooserVariant } from '$/util/experiments';
|
||||
|
||||
const mermaidAiDomain = 'mermaid.ai';
|
||||
const mermaidLiveDomain = 'mermaid.live';
|
||||
@@ -85,20 +84,3 @@ export const shouldShowEditorChooser = (): boolean => {
|
||||
export const dismissEditorChooser = (): void => {
|
||||
window.localStorage.setItem(C.editorChooserDismissedKey, 'true');
|
||||
};
|
||||
|
||||
const isValidVariant = (value: string | null): value is EditorChooserVariant => {
|
||||
return value !== null && (editorChooserVariants as readonly string[]).includes(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the A/B/C test variant assigned to this user for the editor chooser modal.
|
||||
* Assigns a variant on first call (uniform random across variants), persists it
|
||||
* in localStorage, and returns the same value on subsequent calls.
|
||||
*/
|
||||
export const getEditorChooserVariant = (): EditorChooserVariant => {
|
||||
const stored = window.localStorage.getItem(C.editorChooserVariantKey);
|
||||
if (isValidVariant(stored)) return stored;
|
||||
const variant = editorChooserVariants[Math.floor(Math.random() * editorChooserVariants.length)];
|
||||
window.localStorage.setItem(C.editorChooserVariantKey, variant);
|
||||
return variant;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user