feat(invite): branded email + one-command install+launch UX
Email (broker): - Rebrand mesh-invitation.tsx to match site (clay accent #d97757, cream fg, Anthropic Serif/Mono, dark bg). Mesh glyph in header. - Hero CTA links to the /i/short URL landing page. - Single one-liner 'npm i -g claudemesh-cli && claudemesh launch --join URL' so new users copy once, paste once, done. Web InstallToggle: - Replace two-step numbered list with single one-liner in the first-time panel. Reduces copy/paste ops from 2 to 1 and stops prescribing 'YourName' as a literal (CLI now defaults to $USER). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,6 @@ import {
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import * as React from "react";
|
||||
@@ -17,84 +16,295 @@ import * as React from "react";
|
||||
interface MeshInvitationProps {
|
||||
meshName: string;
|
||||
inviteUrl: string;
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
appBaseUrl: string;
|
||||
}
|
||||
|
||||
// Brand tokens — mirror of apps/web/src/assets/styles/globals.css (--cm-*).
|
||||
// Inlined here because email clients don't resolve CSS vars.
|
||||
const brand = {
|
||||
bg: "#141413",
|
||||
bgElevated: "#1f1e1d",
|
||||
bgCode: "#0f0e0d",
|
||||
fg: "#faf9f5",
|
||||
fgSecondary: "#c2c0b6",
|
||||
fgTertiary: "#87867f",
|
||||
clay: "#d97757",
|
||||
clayBorder: "rgba(217, 119, 87, 0.35)",
|
||||
border: "rgba(217, 119, 87, 0.2)",
|
||||
serif: 'Georgia, "Times New Roman", serif',
|
||||
mono: '"JetBrains Mono", "SF Mono", Menlo, Consolas, monospace',
|
||||
sans:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
|
||||
} as const;
|
||||
|
||||
export const MeshInvitation = ({
|
||||
meshName,
|
||||
inviteUrl,
|
||||
token,
|
||||
expiresAt,
|
||||
appBaseUrl,
|
||||
}: MeshInvitationProps) => {
|
||||
const expiresLabel = new Date(expiresAt).toUTCString();
|
||||
const launchCmd = `claudemesh launch --join ${inviteUrl}`;
|
||||
const oneLiner = `npm i -g claudemesh-cli && ${launchCmd}`;
|
||||
|
||||
return (
|
||||
<Html lang="en">
|
||||
<Head />
|
||||
<Head>
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="supported-color-schemes" content="dark" />
|
||||
</Head>
|
||||
<Preview>You've been invited to the {meshName} mesh on claudemesh</Preview>
|
||||
<Tailwind>
|
||||
<Body className="bg-slate-50 font-sans py-10">
|
||||
<Container className="bg-white rounded-xl border border-solid border-slate-200 mx-auto max-w-[520px] p-10">
|
||||
<Section className="mb-8">
|
||||
<Text className="font-mono text-sm font-semibold text-slate-900 tracking-tight m-0">
|
||||
◇ claudemesh
|
||||
</Text>
|
||||
</Section>
|
||||
<Body
|
||||
style={{
|
||||
backgroundColor: brand.bg,
|
||||
color: brand.fg,
|
||||
fontFamily: brand.sans,
|
||||
margin: 0,
|
||||
padding: "40px 0",
|
||||
}}
|
||||
>
|
||||
<Container
|
||||
style={{
|
||||
maxWidth: "560px",
|
||||
margin: "0 auto",
|
||||
padding: "0 24px",
|
||||
}}
|
||||
>
|
||||
{/* Header — mesh glyph + wordmark */}
|
||||
<Section style={{ marginBottom: "40px" }}>
|
||||
<table role="presentation" cellPadding={0} cellSpacing={0} border={0}>
|
||||
<tr>
|
||||
<td style={{ verticalAlign: "middle", paddingRight: "10px" }}>
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle cx="12" cy="4" r="2" fill={brand.clay} />
|
||||
<circle cx="4" cy="12" r="2" fill={brand.clay} />
|
||||
<circle cx="20" cy="12" r="2" fill={brand.clay} />
|
||||
<circle cx="12" cy="20" r="2" fill={brand.clay} />
|
||||
<path
|
||||
d="M12 4L4 12M12 4L20 12M4 12L12 20M20 12L12 20M4 12L20 12M12 4L12 20"
|
||||
stroke={brand.clay}
|
||||
strokeWidth="1.2"
|
||||
opacity="0.45"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
</td>
|
||||
<td style={{ verticalAlign: "middle" }}>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: brand.serif,
|
||||
fontSize: "17px",
|
||||
fontWeight: 500,
|
||||
letterSpacing: "-0.01em",
|
||||
color: brand.fg,
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
claudemesh
|
||||
</Text>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</Section>
|
||||
|
||||
<Heading className="text-[26px] font-semibold tracking-tight text-slate-900 leading-tight mt-0 mb-4">
|
||||
You're invited to join{" "}
|
||||
<span className="font-mono text-indigo-600">{meshName}</span>
|
||||
</Heading>
|
||||
{/* Eyebrow */}
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: brand.mono,
|
||||
fontSize: "11px",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.22em",
|
||||
color: brand.clay,
|
||||
margin: "0 0 16px 0",
|
||||
}}
|
||||
>
|
||||
— you're invited
|
||||
</Text>
|
||||
|
||||
<Text className="text-slate-600 text-base leading-relaxed mt-0 mb-8">
|
||||
Someone invited you to join their mesh on claudemesh — a peer
|
||||
network for Claude Code sessions. Accept the invite to connect
|
||||
your session with theirs.
|
||||
{/* Heading */}
|
||||
<Heading
|
||||
as="h1"
|
||||
style={{
|
||||
fontFamily: brand.serif,
|
||||
fontSize: "32px",
|
||||
fontWeight: 500,
|
||||
lineHeight: "1.15",
|
||||
letterSpacing: "-0.01em",
|
||||
color: brand.fg,
|
||||
margin: "0 0 20px 0",
|
||||
}}
|
||||
>
|
||||
Join{" "}
|
||||
<span style={{ fontFamily: brand.mono, color: brand.clay }}>
|
||||
{meshName}
|
||||
</span>{" "}
|
||||
on claudemesh
|
||||
</Heading>
|
||||
|
||||
{/* Body prose */}
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: brand.serif,
|
||||
fontSize: "16px",
|
||||
lineHeight: "1.65",
|
||||
color: brand.fgSecondary,
|
||||
margin: "0 0 32px 0",
|
||||
}}
|
||||
>
|
||||
claudemesh is a peer mesh for Claude Code sessions — end-to-end
|
||||
encrypted, keys stay on your machine. Open the link below to see
|
||||
the mesh, the inviter, and the command to join.
|
||||
</Text>
|
||||
|
||||
{/* Primary CTA */}
|
||||
<Section style={{ marginBottom: "36px" }}>
|
||||
<Button
|
||||
href={inviteUrl}
|
||||
style={{
|
||||
backgroundColor: brand.clay,
|
||||
color: brand.fg,
|
||||
fontFamily: brand.sans,
|
||||
fontSize: "15px",
|
||||
fontWeight: 500,
|
||||
textDecoration: "none",
|
||||
padding: "14px 28px",
|
||||
borderRadius: "4px",
|
||||
display: "inline-block",
|
||||
}}
|
||||
>
|
||||
Open invite →
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
{/* Terminal shortcut — for the already-set-up crowd */}
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: brand.mono,
|
||||
fontSize: "11px",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.22em",
|
||||
color: brand.fgTertiary,
|
||||
margin: "0 0 12px 0",
|
||||
}}
|
||||
>
|
||||
— already have the CLI?
|
||||
</Text>
|
||||
<Section
|
||||
style={{
|
||||
backgroundColor: brand.bgElevated,
|
||||
border: `1px solid ${brand.clayBorder}`,
|
||||
borderRadius: "6px",
|
||||
padding: "16px 18px",
|
||||
marginBottom: "32px",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: brand.mono,
|
||||
fontSize: "12px",
|
||||
color: brand.fg,
|
||||
margin: 0,
|
||||
wordBreak: "break-all",
|
||||
lineHeight: "1.6",
|
||||
}}
|
||||
>
|
||||
{launchCmd}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Section className="text-center mb-8">
|
||||
<Button
|
||||
href={inviteUrl}
|
||||
className="bg-slate-900 text-white rounded-lg px-6 py-3 text-sm font-medium no-underline box-border"
|
||||
>
|
||||
Accept invite
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text className="text-slate-500 text-sm leading-relaxed mt-0 mb-2">
|
||||
Or copy this link into your browser:
|
||||
{/* First-time one-liner */}
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: brand.mono,
|
||||
fontSize: "11px",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.22em",
|
||||
color: brand.fgTertiary,
|
||||
margin: "0 0 12px 0",
|
||||
}}
|
||||
>
|
||||
— first time? one command
|
||||
</Text>
|
||||
<Section
|
||||
style={{
|
||||
backgroundColor: brand.bgElevated,
|
||||
border: `1px solid ${brand.border}`,
|
||||
borderRadius: "6px",
|
||||
padding: "16px 18px",
|
||||
marginBottom: "32px",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: brand.mono,
|
||||
fontSize: "12px",
|
||||
color: brand.fg,
|
||||
margin: 0,
|
||||
lineHeight: "1.6",
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
>
|
||||
{oneLiner}
|
||||
</Text>
|
||||
<Text className="m-0 mb-8">
|
||||
<Link
|
||||
href={inviteUrl}
|
||||
className="text-indigo-600 text-sm font-mono break-all"
|
||||
>
|
||||
{inviteUrl}
|
||||
</Link>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: brand.serif,
|
||||
fontSize: "12px",
|
||||
color: brand.fgTertiary,
|
||||
margin: "8px 0 0 0",
|
||||
}}
|
||||
>
|
||||
Requires Node.js 20+. Display name defaults to $USER.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Hr className="border-slate-200 my-6" />
|
||||
<Hr
|
||||
style={{
|
||||
border: "none",
|
||||
borderTop: `1px solid ${brand.border}`,
|
||||
margin: "28px 0 20px 0",
|
||||
}}
|
||||
/>
|
||||
|
||||
<Text className="text-slate-400 text-xs leading-relaxed m-0">
|
||||
This invite expires on{" "}
|
||||
<span className="text-slate-500">{expiresLabel}</span>. If you
|
||||
weren't expecting this email, you can safely ignore it.
|
||||
</Text>
|
||||
</Container>
|
||||
|
||||
<Container className="max-w-[520px] mx-auto mt-6 text-center">
|
||||
<Text className="text-slate-400 text-xs m-0">
|
||||
<Link
|
||||
href={appBaseUrl}
|
||||
className="text-slate-400 underline"
|
||||
>
|
||||
claudemesh.com
|
||||
</Link>
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
{/* Footer meta */}
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: brand.serif,
|
||||
fontSize: "13px",
|
||||
lineHeight: "1.6",
|
||||
color: brand.fgTertiary,
|
||||
margin: "0 0 8px 0",
|
||||
}}
|
||||
>
|
||||
Expires{" "}
|
||||
<span style={{ color: brand.fgSecondary }}>{expiresLabel}</span>.
|
||||
If you weren't expecting this, you can ignore it.
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: brand.mono,
|
||||
fontSize: "11px",
|
||||
color: brand.fgTertiary,
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href={appBaseUrl}
|
||||
style={{ color: brand.fgTertiary, textDecoration: "underline" }}
|
||||
>
|
||||
claudemesh.com
|
||||
</Link>
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
@@ -102,6 +312,7 @@ export const MeshInvitation = ({
|
||||
MeshInvitation.PreviewProps = {
|
||||
meshName: "prueba1",
|
||||
inviteUrl: "https://claudemesh.com/i/RUVMYXZQ",
|
||||
token: "eyJ2IjoxLCJtZXNoX2lkIjoiQUtMYUZxR3FKOGZCajN0U3dvVk1PSFYxQmF3UGlYTE8iLCJtZXNoX3NsdWciOiJwcnVlYmExIn0",
|
||||
expiresAt: "2026-04-22T00:51:26.181Z",
|
||||
appBaseUrl: "https://claudemesh.com",
|
||||
} satisfies MeshInvitationProps;
|
||||
|
||||
@@ -5143,7 +5143,7 @@ async function handleCliMeshInvite(req: IncomingMessage, slug: string, res: Serv
|
||||
const { MeshInvitation } = await import("./emails/mesh-invitation");
|
||||
const React = await import("react");
|
||||
const subject = `You're invited to join "${m.name}" on claudemesh`;
|
||||
const element = React.createElement(MeshInvitation, { meshName: m.name, inviteUrl: url, expiresAt: expiresAt.toISOString(), appBaseUrl: baseUrl });
|
||||
const element = React.createElement(MeshInvitation, { meshName: m.name, inviteUrl: url, token, expiresAt: expiresAt.toISOString(), appBaseUrl: baseUrl });
|
||||
const html = await render(element);
|
||||
const text = await render(element, { plainText: true });
|
||||
const res = process.env.POSTMARK_API_KEY
|
||||
|
||||
@@ -5,8 +5,9 @@ interface Props {
|
||||
token: string;
|
||||
}
|
||||
|
||||
const LAUNCH_CMD = (token: string) => `claudemesh launch --name YourName --join ${token}`;
|
||||
const JOIN_CMD = (token: string) => `claudemesh join ${token}`;
|
||||
const LAUNCH_CMD = (token: string) => `claudemesh launch --join ${token}`;
|
||||
const INSTALL_AND_LAUNCH = (token: string) =>
|
||||
`npm i -g claudemesh-cli && claudemesh launch --join ${token}`;
|
||||
const INSTALL_CMD = "npm i -g claudemesh-cli";
|
||||
|
||||
export const InstallToggle = ({ token }: Props) => {
|
||||
@@ -97,71 +98,39 @@ export const InstallToggle = ({ token }: Props) => {
|
||||
);
|
||||
}
|
||||
|
||||
const launchCmd = LAUNCH_CMD(token);
|
||||
const oneLiner = INSTALL_AND_LAUNCH(token);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ol className="space-y-3">
|
||||
<li className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-5">
|
||||
<div
|
||||
className="mb-2 flex items-center gap-2 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-clay)]"
|
||||
<div className="rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/40 bg-[var(--cm-bg-elevated)] p-5">
|
||||
<div
|
||||
className="mb-2 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
install + launch — one command
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
className="flex-1 overflow-x-auto rounded-[var(--cm-radius-xs)] bg-[var(--cm-bg)] p-3 text-sm text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span className="rounded-full bg-[var(--cm-clay)]/20 px-1.5">1</span>
|
||||
install the CLI
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
className="flex-1 overflow-x-auto rounded-[var(--cm-radius-xs)] bg-[var(--cm-bg)] p-3 text-sm text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{INSTALL_CMD}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copy(INSTALL_CMD, "install")}
|
||||
className="rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] px-3 py-3 text-sm text-[var(--cm-fg-secondary)] transition-colors hover:border-[var(--cm-fg)] hover:text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
{copiedKey === "install" ? "Copied ✓" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
className="mt-2 text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
{oneLiner}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copy(oneLiner, "one")}
|
||||
className="rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-4 py-3 text-sm font-medium text-[var(--cm-fg)] transition-colors hover:bg-[var(--cm-clay-hover)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
Requires Node.js 20+.
|
||||
</p>
|
||||
</li>
|
||||
<li className="rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/40 bg-[var(--cm-bg-elevated)] p-5">
|
||||
<div
|
||||
className="mb-2 flex items-center gap-2 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span className="rounded-full bg-[var(--cm-clay)]/20 px-1.5">2</span>
|
||||
join + launch
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
className="flex-1 overflow-x-auto rounded-[var(--cm-radius-xs)] bg-[var(--cm-bg)] p-3 text-sm text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{launchCmd}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copy(launchCmd, "join")}
|
||||
className="rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-3 py-3 text-sm font-medium text-[var(--cm-fg)] transition-colors hover:bg-[var(--cm-clay-hover)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
{copiedKey === "join" ? "Copied ✓" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
className="mt-2 text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Joins the mesh and launches Claude Code in one step.
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
{copiedKey === "one" ? "Copied ✓" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
className="mt-2 text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Requires Node.js 20+. Your display name defaults to <code style={{ fontFamily: "var(--cm-font-mono)" }}>$USER</code> — override with{" "}
|
||||
<code style={{ fontFamily: "var(--cm-font-mono)" }}>--name YourName</code>.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setHasCli("unknown")}
|
||||
className="text-xs text-[var(--cm-fg-tertiary)] underline underline-offset-4 hover:text-[var(--cm-fg)]"
|
||||
|
||||
Reference in New Issue
Block a user