Merge pull request #1938 from mermaid-js/sidv/editorChooserExperiments

feat(MC-4486): Add variations to editor picker
This commit is contained in:
Sidharth Vinod
2026-04-23 14:32:44 +05:30
committed by GitHub
16 changed files with 578 additions and 155 deletions
+3
View File
@@ -12,11 +12,14 @@
"fsegurai", "fsegurai",
"gantt", "gantt",
"gitgraph", "gitgraph",
"hugeicons",
"KROKI", "KROKI",
"localstorage", "localstorage",
"mermaidchart", "mermaidchart",
"mindmap", "mindmap",
"NEWYEAR", "NEWYEAR",
"noopener",
"noreferrer",
"Pageview", "Pageview",
"pako", "pako",
"panmove", "panmove",
+2
View File
@@ -27,6 +27,8 @@
"@eslint/eslintrc": "^3.3.3", "@eslint/eslintrc": "^3.3.3",
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@fortawesome/fontawesome-free": "^6.7.2", "@fortawesome/fontawesome-free": "^6.7.2",
"@iconify-json/hugeicons": "^1.2.26",
"@iconify-json/logos": "^1.2.11",
"@iconify-json/material-symbols": "^1.2.20", "@iconify-json/material-symbols": "^1.2.20",
"@iconify-json/mdi": "^1.2.3", "@iconify-json/mdi": "^1.2.3",
"@playwright/test": "^1.52.0", "@playwright/test": "^1.52.0",
+20
View File
@@ -102,6 +102,12 @@ importers:
'@fortawesome/fontawesome-free': '@fortawesome/fontawesome-free':
specifier: ^6.7.2 specifier: ^6.7.2
version: 6.7.2 version: 6.7.2
'@iconify-json/hugeicons':
specifier: ^1.2.26
version: 1.2.26
'@iconify-json/logos':
specifier: ^1.2.11
version: 1.2.11
'@iconify-json/material-symbols': '@iconify-json/material-symbols':
specifier: ^1.2.20 specifier: ^1.2.20
version: 1.2.20 version: 1.2.20
@@ -702,6 +708,12 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'} engines: {node: '>=18.18'}
'@iconify-json/hugeicons@1.2.26':
resolution: {integrity: sha512-ZJB0EjFrYJy+EBUnx2/mGD4MCGScQr4dauV455lC9CuCt50guk6TosnOi9t2r4EIyhHmytO5IOeVFG4C8V9/hw==}
'@iconify-json/logos@1.2.11':
resolution: {integrity: sha512-fOo4pGEatuyuCFNL+cwquYMa2Im0oJHRHV7lt/Qqs5Ode/lPImHCQcfTtPzZj7qYMPb/h8YHN3TG54uEowrjNQ==}
'@iconify-json/material-symbols@1.2.20': '@iconify-json/material-symbols@1.2.20':
resolution: {integrity: sha512-+KqOT+3fD+LC2FbWiV8gd4+JLMiVUtmqrjzpKN1ji7rfMQTwvYJ94RT0WQlmL+vfDNJ5MTRe3rBzzJyvIH/aSg==} resolution: {integrity: sha512-+KqOT+3fD+LC2FbWiV8gd4+JLMiVUtmqrjzpKN1ji7rfMQTwvYJ94RT0WQlmL+vfDNJ5MTRe3rBzzJyvIH/aSg==}
@@ -4549,6 +4561,14 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {} '@humanwhocodes/retry@0.4.3': {}
'@iconify-json/hugeicons@1.2.26':
dependencies:
'@iconify/types': 2.0.0
'@iconify-json/logos@1.2.11':
dependencies:
'@iconify/types': 2.0.0
'@iconify-json/material-symbols@1.2.20': '@iconify-json/material-symbols@1.2.20':
dependencies: dependencies:
'@iconify/types': 2.0.0 '@iconify/types': 2.0.0
+5 -1
View File
@@ -224,7 +224,11 @@
onClose={closePopup} onClose={closePopup}
onTryFree={() => { onTryFree={() => {
logMermaidChartClick('vibeDiagramming'); logMermaidChartClick('vibeDiagramming');
window.open($urlsStore.mermaidChart({ medium: 'vibe_diagramming' }).save, '_blank'); window.open(
$urlsStore.mermaidChart({ medium: 'vibe_diagramming' }).save,
'_blank',
'noopener'
);
closePopup(); closePopup();
}} /> }} />
</div> </div>
@@ -0,0 +1,118 @@
<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,164 +1,42 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$/components/ui/button';
import * as Dialog from '$/components/ui/dialog'; import * as Dialog from '$/components/ui/dialog';
import { dismissEditorChooser } from '$/util/migration/domainMigration'; import { dismissEditorChooser, getEditorChooserVariant } from '$/util/migration/domainMigration';
import { logEvent, logMermaidChartClick } from '$/util/stats'; import EditorChooserControl from './EditorChooserControl.svelte';
import { getCheckoutUrl } from '$/util/util'; import EditorChooserTestA from './EditorChooserTestA.svelte';
import CodeIcon from '~icons/custom/code'; import EditorChooserTestB from './EditorChooserTestB.svelte';
import ArrowIcon from '~icons/material-symbols/arrow-forward-rounded'; import EditorChooserTestC from './EditorChooserTestC.svelte';
import OpenSourceIcon from '~icons/material-symbols/book-2-outline-rounded'; import { createEditorChooserActions } from './editorChooserActions';
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';
interface Props { interface Props {
open: boolean; open: boolean;
} }
let { open = $bindable() }: Props = $props(); let { open = $bindable() }: Props = $props();
const variant = getEditorChooserVariant();
const close = () => { // Tracks whether the current close was triggered by an explicit action
dismissEditorChooser(); // (button click logs its own event) vs. implicit dismissal (ESC/click-outside).
let handled = false;
const actions = createEditorChooserActions(variant, () => {
handled = true;
open = false; open = false;
}; });
const handleStartTrial = () => {
logEvent('chooseEditor', { choice: 'plus' });
logMermaidChartClick('editorPicker');
close();
window.open(
getCheckoutUrl({ utmCampaign: 'start_plus', utmMedium: '2_editor_selection' }),
'_blank'
);
};
const handleStartFree = () => {
logEvent('chooseEditor', { choice: 'openSource' });
close();
};
const handleContinueToNewHome = () => {
logEvent('chooseEditor', { choice: 'newHome' });
close();
window.open('https://mermaid.ai/live', '_blank');
};
</script> </script>
<Dialog.Root <Dialog.Root
bind:open bind:open
onOpenChange={(v) => { onOpenChange={(v) => {
if (!v) handleStartFree(); if (v) return;
if (!handled) actions.log('dismissed');
handled = false;
dismissEditorChooser();
}}> }}>
<Dialog.Content class="flex max-w-2xl flex-col gap-6 bg-pink-50 p-6 dark:bg-background"> {#if variant === 'testA'}
<Dialog.Header> <EditorChooserTestA {actions} />
<Dialog.Title class="text-center text-2xl font-semibold">Choose your editor</Dialog.Title> {:else if variant === 'testB'}
<Dialog.Description class="text-center text-sm font-light"> <EditorChooserTestB {actions} />
You'll never see this again {:else if variant === 'testC'}
</Dialog.Description> <EditorChooserTestC {actions} />
</Dialog.Header> {:else}
<EditorChooserControl {actions} />
<div class="grid gap-4 sm:grid-cols-2"> {/if}
<!-- 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>
<Button variant="accent" class="w-full" onclick={handleStartTrial}>
Start free trial
</Button>
</div>
<ul class="space-y-3 text-sm">
<li class="flex items-center gap-2">
<EditIcon class="size-4 shrink-0 text-muted-foreground" />
Visual editor
</li>
<li class="flex items-center gap-2">
<SparklesIcon class="size-4 shrink-0 text-muted-foreground" />
300 AI credits
</li>
<li class="flex items-center gap-2">
<HomeIcon class="size-4 shrink-0 text-muted-foreground" />
Unlimited diagram storage
</li>
<li class="flex items-center gap-2">
<WidthIcon class="size-4 shrink-0 text-muted-foreground" />
Limitless diagram size
</li>
<li class="flex items-center gap-2">
<ChatIcon class="size-4 shrink-0 text-muted-foreground" />
View & comment collaboration
</li>
</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>
<Button variant="outline" class="w-full border-accent" onclick={handleContinueToNewHome}>
Continue to mermaid.ai/live
<ArrowIcon class="ml-1 size-4" />
</Button>
<Button variant="outline" class="w-full" onclick={handleStartFree}>
Stay on mermaid.live
</Button>
</div>
<ul class="space-y-3 text-sm">
<li class="flex items-center gap-2">
<LanguageIcon class="size-4 shrink-0 text-muted-foreground" />
Diagram stored in URL
</li>
<li class="flex items-center gap-2">
<CodeIcon class="size-4 shrink-0 text-muted-foreground" />
Code editor
</li>
<li class="flex items-center gap-2">
<OpenSourceIcon class="size-4 shrink-0 text-muted-foreground" />
Open source
</li>
<li class="flex items-center gap-2">
<HistoryIcon class="size-4 shrink-0 text-muted-foreground" />
Version history
</li>
</ul>
</div>
</div>
<div class="text-center">
<a
href="https://mermaid.ai/privacy-policy"
target="_blank"
class="text-sm text-foreground underline hover:text-accent">
mermaid.ai Privacy Policy
</a>
</div>
</Dialog.Content>
</Dialog.Root> </Dialog.Root>
@@ -0,0 +1,94 @@
<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>
@@ -0,0 +1,80 @@
<script lang="ts">
import McWrapper from '$/components/McWrapper.svelte';
import MermaidChartIcon from '$/components/MermaidChartIcon.svelte';
import PrivacyPolicyLink from '$/components/migration/PrivacyPolicyLink.svelte';
import { Button } from '$/components/ui/button';
import * as Dialog from '$/components/ui/dialog';
import type { Component } from 'svelte';
import AtlassianIcon from '~icons/logos/atlassian';
import AmazonIcon from '~icons/logos/aws';
import GoogleIcon from '~icons/logos/google';
import MicrosoftIcon from '~icons/logos/microsoft';
import type { EditorChooserVariantProps } from './editorChooserActions';
let { actions }: EditorChooserVariantProps = $props();
const features = [
{ title: 'AI diagram generation', description: 'Describe what you need, AI builds it' },
{
title: 'Visual drag-and-drop editor',
description: 'Edit diagrams without writing code'
},
{
title: 'Unlimited diagram storage',
description: 'Save and organize all your diagrams'
},
{
title: 'Team collaboration',
description: 'Share, comment, and edit together in real-time'
}
];
const trustedLogos: { name: string; icon: Component }[] = [
{ name: 'Google', icon: GoogleIcon },
{ name: 'Microsoft', icon: MicrosoftIcon },
{ name: 'Atlassian', icon: AtlassianIcon },
{ name: 'Amazon', icon: AmazonIcon }
];
</script>
<Dialog.Content class="flex max-w-lg flex-col gap-3 bg-background p-8">
<Dialog.Header class="flex-col items-start gap-2 space-y-0 text-left sm:text-left">
<MermaidChartIcon class="size-10" />
<Dialog.Title class="pt-2 text-2xl font-bold">Try the full Mermaid experience</Dialog.Title>
<Dialog.Description class="text-xs font-light text-muted-foreground">
Free forever, with Plus features free for 7 days.
</Dialog.Description>
</Dialog.Header>
<ul class="mt-2 flex flex-col gap-3">
{#each features as feature (feature.title)}
<li class="flex items-center gap-3 rounded-lg border border-border p-3">
<span class="size-2 shrink-0 rounded-full bg-accent"></span>
<div class="flex flex-col gap-0.5">
<p class="text-xs">{feature.title}</p>
<p class="text-xs font-light text-muted-foreground">{feature.description}</p>
</div>
</li>
{/each}
</ul>
<div class="mt-2 flex items-center gap-3">
<McWrapper labelPrefix="Opens ">
<Button variant="accent" onclick={() => actions.startTrial()}>Start free trial</Button>
</McWrapper>
<Button variant="outline" onclick={() => actions.dismiss('stayOnLive')}>
Stay on mermaid.live
</Button>
</div>
<div class="mt-3 flex flex-col items-start gap-4">
<p class="text-xs">Trusted by 5M people and over 200k companies</p>
<div class="flex w-full items-center justify-between gap-2">
{#each trustedLogos as logo (logo.name)}
<logo.icon class="h-6 w-auto grayscale" aria-label={logo.name} />
{/each}
</div>
</div>
<PrivacyPolicyLink />
</Dialog.Content>
@@ -0,0 +1,99 @@
<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>
@@ -0,0 +1,8 @@
<div class="text-center">
<a
href="https://mermaid.ai/privacy-policy"
target="_blank"
class="text-sm text-foreground underline hover:text-accent">
mermaid.ai Privacy Policy
</a>
</div>
@@ -0,0 +1,74 @@
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;
};
export interface EditorChooserActions {
log: (buttonClick: string) => void;
startTrial: (buttonClick?: string) => void;
dismiss: (buttonClick: string) => void;
openMermaidAiLive: (buttonClick: string) => void;
}
export interface EditorChooserVariantProps {
actions: EditorChooserActions;
}
export const createEditorChooserActions = (
variant: EditorChooserVariant,
close: () => void
): EditorChooserActions => {
const log = (buttonClick: string) => {
logEvent('chooseEditor', { buttonClick, variant });
};
const startTrial = (buttonClick = 'startTrial') => {
log(buttonClick);
logMermaidChartClick('editorPicker');
close();
window.open(
getCheckoutUrl({
utmCampaign: resolveUtmCampaign(variant, buttonClick),
utmMedium
}),
'_blank',
'noopener'
);
};
const dismiss = (buttonClick: string) => {
log(buttonClick);
close();
};
const openMermaidAiLive = (buttonClick: string) => {
log(buttonClick);
close();
window.open(
getMermaidAiLiveUrl({
utmCampaign: resolveUtmCampaign(variant, buttonClick),
utmMedium
}),
'_blank',
'noopener'
);
};
return { log, startTrial, dismiss, openMermaidAiLive };
};
+1
View File
@@ -12,5 +12,6 @@ export const TID = {
export const C = { export const C = {
aiLiveEditor: 'ai_live_editor', aiLiveEditor: 'ai_live_editor',
editorChooserDismissedKey: 'mermaid-editor-chooser-dismissed', editorChooserDismissedKey: 'mermaid-editor-chooser-dismissed',
editorChooserVariantKey: 'mermaid-editor-chooser-variant',
utmSource: 'mermaid_live_editor' utmSource: 'mermaid_live_editor'
} as const; } as const;
+2
View File
@@ -0,0 +1,2 @@
export const editorChooserVariants = ['control', 'testA', 'testB', 'testC'] as const;
export type EditorChooserVariant = (typeof editorChooserVariants)[number];
+31 -1
View File
@@ -1,8 +1,10 @@
import { C } from '$/constants'; import { C } from '$/constants';
import { env } from '$/util/env'; import { env } from '$/util/env';
import { editorChooserVariants, type EditorChooserVariant } from '$/util/experiments';
const mermaidAiDomain = 'mermaid.ai'; const mermaidAiDomain = 'mermaid.ai';
const mermaidLiveDomain = 'mermaid.live'; const mermaidLiveDomain = 'mermaid.live';
const netlifyPreviewDomain = 'netlify.app';
/** /**
* Check if we're on mermaid.ai * Check if we're on mermaid.ai
@@ -20,6 +22,13 @@ export const isOnMermaidLive = (): boolean => {
return domain === mermaidLiveDomain || domain.endsWith(`.${mermaidLiveDomain}`); return domain === mermaidLiveDomain || domain.endsWith(`.${mermaidLiveDomain}`);
}; };
/**
* Check if we're on a Netlify preview/staging deploy (*.netlify.app).
*/
const isOnNetlifyPreview = (): boolean => {
return window.location.hostname.endsWith(`.${netlifyPreviewDomain}`);
};
/** /**
* Check if the current URL has pako data (diagram content from a shared link). * Check if the current URL has pako data (diagram content from a shared link).
*/ */
@@ -55,11 +64,15 @@ const isReferredFromMermaid = (): boolean => {
* Check if the editor chooser modal should be shown. * Check if the editor chooser modal should be shown.
* Shows for new users who haven't dismissed it and aren't viewing a shared link. * Shows for new users who haven't dismissed it and aren't viewing a shared link.
* Not shown on mobile (viewport width < 640px). * Not shown on mobile (viewport width < 640px).
* Can be forced open for QA via the `?editorChooser=1` query flag, which bypasses
* the hostname and dismissed checks.
*/ */
export const shouldShowEditorChooser = (): boolean => { export const shouldShowEditorChooser = (): boolean => {
if (!env.isEnabledMermaidChartLinks) return false; if (!env.isEnabledMermaidChartLinks) return false;
if (!isOnMermaidAI() && !isOnMermaidLive()) return false;
if (window.innerWidth < 640) return false; if (window.innerWidth < 640) return false;
const forced = new URLSearchParams(window.location.search).get('editorChooser') === '1';
if (forced) return true;
if (!isOnMermaidAI() && !isOnMermaidLive() && !isOnNetlifyPreview()) return false;
if (window.localStorage.getItem(C.editorChooserDismissedKey) === 'true') return false; if (window.localStorage.getItem(C.editorChooserDismissedKey) === 'true') return false;
if (hasPakoData()) return false; if (hasPakoData()) return false;
if (isReferredFromMermaid()) return false; if (isReferredFromMermaid()) return false;
@@ -72,3 +85,20 @@ export const shouldShowEditorChooser = (): boolean => {
export const dismissEditorChooser = (): void => { export const dismissEditorChooser = (): void => {
window.localStorage.setItem(C.editorChooserDismissedKey, 'true'); 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;
};
+3
View File
@@ -113,6 +113,9 @@ export const logEvent = (
name: AnalyticsEvent, name: AnalyticsEvent,
data?: Record<string, string | number | boolean> data?: Record<string, string | number | boolean>
): void => { ): void => {
if (browser && window.location.hostname === 'localhost') {
console.log('[plausible]', name, data);
}
if (!plausible) { if (!plausible) {
return; return;
} }
+12 -5
View File
@@ -42,23 +42,30 @@ export const MCBaseURL = env.isEnabledMermaidChartLinks
? 'https://mermaid.ai' // 'http://localhost:5174' ? 'https://mermaid.ai' // 'http://localhost:5174'
: 'https://example.com'; : 'https://example.com';
export const getCheckoutUrl = ({ const buildUtmParams = ({
utmCampaign, utmCampaign,
utmMedium utmMedium
}: { }: {
utmCampaign: string; utmCampaign: string;
utmMedium: string; utmMedium: string;
}): string => { }): URLSearchParams =>
const params = new URLSearchParams({ new URLSearchParams({
coupon: 'arDfyFT8',
tier: 'plus',
utm_campaign: utmCampaign, utm_campaign: utmCampaign,
utm_medium: utmMedium, utm_medium: utmMedium,
utm_source: getUTMSource() utm_source: getUTMSource()
}); });
export const getCheckoutUrl = (utm: { utmCampaign: string; utmMedium: string }): string => {
const params = buildUtmParams(utm);
params.set('coupon', 'arDfyFT8');
params.set('tier', 'plus');
return `${MCBaseURL}/app/user/billing/checkout?${params.toString()}`; return `${MCBaseURL}/app/user/billing/checkout?${params.toString()}`;
}; };
export const getMermaidAiLiveUrl = (utm: { utmCampaign: string; utmMedium: string }): string => {
return `${MCBaseURL}/live?${buildUtmParams(utm).toString()}`;
};
let count = 0; let count = 0;
export const errorDebug = (limit = 1000) => { export const errorDebug = (limit = 1000) => {
count += 1; count += 1;