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
Binary file added apps/design/public/email/locusgraph-mark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/design/public/email/spendgraph-mark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
116 changes: 110 additions & 6 deletions apps/design/src/demos/brand/media/email.demo.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
import {
createEmails,
EmailButton,
EmailHeading,
EmailLabel,
EmailLayout,
EmailRows,
EmailText,
emailProducts,
formatMicroUsd,
lowCreditsEmail,
} from "@locusgraph/email";
import { defineDemo, select } from "../../../registry/define-demo";
import { EmailFrame, KitPreview, PlainTextPart } from "./email/email-frame";
import {
brandOnceCode,
outOfCreditsCode,
overrideTextCode,
plainTextCode,
} from "./email/example-code";
import { emailTemplates, renderSampleEmail, templateCode } from "./email/samples";

const outOfCreditsEmail = lowCreditsEmail({
Expand Down Expand Up @@ -36,6 +45,36 @@ function ComposeFromKit() {
);
}

const brandedWelcome = createEmails({
product: "spendgraph",
brand: { name: "Acme Cloud", wordmark: ["acme", "cloud"] },
overrides: { welcome: { body: "Connect your first agent to start tracking spend." } },
}).welcome({
userName: "Dana Rivera",
dashboardUrl: "https://app.acme.com",
docsUrl: "https://docs.acme.com",
});

function BrandOnce() {
return <EmailFrame email={brandedWelcome} />;
}

const budgetEmail = lowCreditsEmail({
organizationName: "Acme Inc",
balanceMicroUsd: 2_400_000,
buyCreditsUrl: "https://accounts.locusgraph.com/billing/credits",
overrides: {
heading: "Budget running low",
body: (p) =>
`${p.organizationName} has ${formatMicroUsd(p.balanceMicroUsd)} left in its budget.`,
button: "Add budget",
},
});

function OverrideText() {
return <EmailFrame email={budgetEmail} />;
}

function OutOfCredits() {
return <EmailFrame email={outOfCreditsEmail} />;
}
Expand All @@ -47,32 +86,97 @@ function PlainText() {
export default defineDemo({
title: "Email",
description:
"The branded email kit from @locusgraph/email: a 560px layout with the wordmark, a mono label, a heading, body text, ruled rows and one pill button, all with inline styles from tokens. Each template returns a subject, HTML and a plain text part.",
controls: { template: select(emailTemplates, "invitation") },
playground: ({ template }) => <EmailFrame email={renderSampleEmail(template)} />,
code: ({ template }) =>
`import { ${templateCode[template].split("(")[0]} } from "@locusgraph/email";\n\nconst { subject, html, text } = ${templateCode[template]};`,
"The branded email kit from @locusgraph/email: a 560px layout with the product mark and wordmark, a mono label, a heading, body text, ruled rows and one pill button, all with inline styles from tokens. Twenty templates cover account, organization, members, keys, billing, usage and agent approval; each returns a subject, HTML and a plain text part. Pass `product`, a `brand` override for the logo, name and wordmark, and `overrides` for any text slot, or set them once with `createEmails`. Every template also gets a raw link under its button and, for security emails, a calm footer.",
controls: {
template: select(emailTemplates, "invitation"),
product: select(emailProducts, "locusgraph"),
},
playground: ({ template, product }) => (
<EmailFrame email={renderSampleEmail(template, product)} />
),
code: ({ template, product }) => templateCode(template, product),
props: [
{ name: "welcomeEmail", type: "(props: WelcomeEmailProps) => RenderedEmail" },
{
name: "emailVerificationEmail",
type: "(props: EmailVerificationEmailProps) => RenderedEmail",
},
{ name: "paymentFailedEmail", type: "(props: PaymentFailedEmailProps) => RenderedEmail" },
{ name: "planChangedEmail", type: "(props: PlanChangedEmailProps) => RenderedEmail" },
{
name: "spendLimitReachedEmail",
type: "(props: SpendLimitReachedEmailProps) => RenderedEmail",
},
{
name: "approvalRequestedEmail",
type: "(props: ApprovalRequestedEmailProps) => RenderedEmail",
},
{ name: "invitationEmail", type: "(props: InvitationEmailProps) => RenderedEmail" },
{ name: "receiptEmail", type: "(props: ReceiptEmailProps) => RenderedEmail" },
{ name: "lowCreditsEmail", type: "(props: LowCreditsEmailProps) => RenderedEmail" },
{ name: "usageOverPlanEmail", type: "(props: UsageOverPlanEmailProps) => RenderedEmail" },
{ name: "usageWarningEmail", type: "(props: UsageWarningEmailProps) => RenderedEmail" },
{ name: "usageSummaryEmail", type: "(props: UsageSummaryEmailProps) => RenderedEmail" },
{
name: "invitationAcceptedEmail",
type: "(props: InvitationAcceptedEmailProps) => RenderedEmail",
},
{ name: "roleChangedEmail", type: "(props: RoleChangedEmailProps) => RenderedEmail" },
{ name: "memberRemovedEmail", type: "(props: MemberRemovedEmailProps) => RenderedEmail" },
{
name: "ownershipTransferredEmail",
type: "(props: OwnershipTransferredEmailProps) => RenderedEmail",
},
{
name: "organizationDeletedEmail",
type: "(props: OrganizationDeletedEmailProps) => RenderedEmail",
},
{ name: "keyCreatedEmail", type: "(props: KeyCreatedEmailProps) => RenderedEmail" },
{ name: "keyRevokedEmail", type: "(props: KeyRevokedEmailProps) => RenderedEmail" },
{ name: "keyExpiringEmail", type: "(props: KeyExpiringEmailProps) => RenderedEmail" },
{
name: "product",
type: '"locusgraph" | "spendgraph"',
defaultValue: '"locusgraph"',
},
{
name: "brand",
type: "Partial<{ name; wordmark: [bold, light]; markUrl; markWidth; markHeight }>",
},
{
name: "createEmails",
type: "(defaults?: { product?; brand?; overrides? }) => BrandedEmails",
},
{ name: "renderEmail", type: "(subject: string, email: ReactElement) => RenderedEmail" },
{ name: "RenderedEmail", type: "{ subject: string; html: string; text: string }" },
],
examples: {
ComposeFromKit: {
description:
"A new email built from the kit: `EmailLayout`, `EmailLabel`, `EmailHeading`, `EmailText`, `EmailRows` and `EmailButton`, rendered with `renderEmail`.",
"A new email built from the kit: `EmailLayout`, `EmailLabel`, `EmailHeading`, `EmailText`, `EmailRows`, `EmailLink` and `EmailButton`, rendered with `renderEmail`.",
component: ComposeFromKit,
},
BrandOnce: {
description:
"`createEmails` sets the product, brand and per-template `overrides` once and returns every template under a short name, so `emails.welcome(...)` needs only its own props.",
component: BrandOnce,
code: brandOnceCode,
},
OverrideText: {
description:
"`overrides` replaces any text slot (`label`, `heading`, `body`, `button`, `note`, `footer`, `subject`, `preview`) with a string or a function of the props and `brandName`. Slots left out keep the default; an empty string removes one.",
component: OverrideText,
code: overrideTextCode,
},
OutOfCredits: {
description: "`lowCreditsEmail` with a zero balance switches to the out-of-credits copy.",
component: OutOfCredits,
code: outOfCreditsCode,
},
PlainText: {
description: "Every template also renders a plain text part for clients that block HTML.",
component: PlainText,
code: plainTextCode,
},
},
isNew: true,
Expand Down
9 changes: 7 additions & 2 deletions apps/design/src/demos/brand/media/email/email-frame.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type RenderedEmail, renderEmail } from "@locusgraph/email";
import { emailAssetsOrigin, type RenderedEmail, renderEmail } from "@locusgraph/email";
import type { ReactElement, SyntheticEvent } from "react";

function fitFrameToEmail(event: SyntheticEvent<HTMLIFrameElement>) {
Expand All @@ -10,6 +10,11 @@ function fitFrameToEmail(event: SyntheticEvent<HTMLIFrameElement>) {

const placeholderHeight = 560;

function withLocalAssets(html: string) {
if (typeof window === "undefined") return html;
return html.replaceAll(emailAssetsOrigin, window.location.origin);
}

export function EmailFrame({ email }: { email: RenderedEmail }) {
return (
<section className="flex w-[40rem] max-w-full flex-col ring-1 ring-border">
Expand All @@ -19,7 +24,7 @@ export function EmailFrame({ email }: { email: RenderedEmail }) {
</div>
<iframe
title={email.subject}
srcDoc={email.html}
srcDoc={withLocalAssets(email.html)}
className="w-full bg-white"
style={{ height: placeholderHeight }}
onLoad={fitFrameToEmail}
Expand Down
48 changes: 48 additions & 0 deletions apps/design/src/demos/brand/media/email/example-code.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
export const brandOnceCode = `import { createEmails } from "@locusgraph/email";

export const emails = createEmails({
product: "spendgraph",
brand: { name: "Acme Cloud", wordmark: ["acme", "cloud"] },
overrides: {
welcome: { body: "Connect your first agent to start tracking spend." },
},
});

const { subject, html, text } = emails.welcome({
userName: "Dana Rivera",
dashboardUrl: "https://app.acme.com",
docsUrl: "https://docs.acme.com",
});`;

export const overrideTextCode = `import { formatMicroUsd, lowCreditsEmail } from "@locusgraph/email";

const { subject, html, text } = lowCreditsEmail({
organizationName: "Acme Inc",
balanceMicroUsd: 2_400_000,
buyCreditsUrl: "https://accounts.locusgraph.com/billing/credits",
overrides: {
heading: "Budget running low",
body: (p) => \`\${p.organizationName} has \${formatMicroUsd(p.balanceMicroUsd)} left in its budget.\`,
button: "Add budget",
},
});`;

export const outOfCreditsCode = `import { lowCreditsEmail } from "@locusgraph/email";

const { subject, html, text } = lowCreditsEmail({
organizationName: "Acme Inc",
balanceMicroUsd: 0,
buyCreditsUrl: "https://accounts.locusgraph.com/billing/credits",
});`;

export const plainTextCode = `import { receiptEmail } from "@locusgraph/email";

const { subject, html, text } = receiptEmail({
organizationName: "Acme Inc",
description: "Pro plan, October",
amountCents: 4000,
paidOn: "Oct 1, 2026",
receiptUrl: "https://pay.stripe.com/receipts/example",
});

await mailer.send({ subject, html, text });`;
128 changes: 128 additions & 0 deletions apps/design/src/demos/brand/media/email/sample-props.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
const organizationName = "Acme Inc";
const keys = {
organizationName,
keyName: "ci-deploy",
keysUrl: "https://accounts.locusgraph.com/keys",
};
const quotas = [
{
key: "locusgraph.memory_writes",
label: "Memory writes",
used: 124_000,
included: 100_000,
periodLabel: "this month",
},
{ key: "locusgraph.graphs", label: "Graphs", used: 6, included: 5, periodLabel: "in total" },
];

export const sampleProps = {
welcomeEmail: {
userName: "Dana Rivera",
dashboardUrl: "https://accounts.locusgraph.com/",
docsUrl: "https://docs.locusgraph.com/",
},
emailVerificationEmail: {
code: "482913",
verifyUrl: "https://accounts.locusgraph.com/verify/example",
expiresInMinutes: 30,
},
invitationEmail: {
organizationName,
inviterName: "Dana Rivera",
role: "member",
acceptUrl: "https://accounts.locusgraph.com/invite/example",
expiresOn: "Oct 24, 2026",
},
invitationAcceptedEmail: {
organizationName,
memberName: "Sam Lee",
role: "admin",
membersUrl: "https://accounts.locusgraph.com/members",
},
roleChangedEmail: {
organizationName,
role: "admin",
changedBy: "Dana Rivera",
organizationUrl: "https://accounts.locusgraph.com/",
},
memberRemovedEmail: { organizationName, removedBy: "Dana Rivera" },
ownershipTransferredEmail: {
organizationName,
previousOwnerName: "Dana Rivera",
newOwnerName: "Sam Lee",
transferredOn: "Nov 3, 2026",
organizationUrl: "https://accounts.locusgraph.com/",
},
organizationDeletedEmail: {
organizationName,
deletedBy: "Dana Rivera",
deletedOn: "Nov 3, 2026",
},
keyCreatedEmail: { ...keys, createdBy: "Dana Rivera", createdOn: "Nov 3, 2026" },
keyRevokedEmail: { ...keys, revokedBy: "Dana Rivera", revokedOn: "Nov 3, 2026" },
keyExpiringEmail: { ...keys, expiresOn: "Nov 10, 2026" },
lowCreditsEmail: {
organizationName,
balanceMicroUsd: 2_400_000,
buyCreditsUrl: "https://accounts.locusgraph.com/billing/credits",
},
receiptEmail: {
organizationName,
description: "Pro plan, October",
amountCents: 4000,
paidOn: "Oct 1, 2026",
receiptUrl: "https://pay.stripe.com/receipts/example",
},
paymentFailedEmail: {
organizationName,
amountCents: 4000,
failedOn: "Nov 1, 2026",
reason: "Card declined",
retryOn: "Nov 4, 2026",
billingUrl: "https://accounts.locusgraph.com/billing",
},
planChangedEmail: {
organizationName,
previousPlan: "Free",
newPlan: "Pro",
effectiveOn: "Nov 4, 2026",
changedBy: "Dana Rivera",
billingUrl: "https://accounts.locusgraph.com/billing",
},
spendLimitReachedEmail: {
organizationName,
limitCents: 10_000,
reachedOn: "Nov 4, 2026",
resetsOn: "Dec 1, 2026",
limitsUrl: "https://accounts.locusgraph.com/billing/limits",
},
approvalRequestedEmail: {
organizationName,
agentName: "Deploy bot",
action: "Delete graph prod-memory",
requestedOn: "Nov 4, 2026, 10:42",
expiresOn: "Nov 5, 2026, 10:42",
reviewUrl: "https://accounts.locusgraph.com/approvals/example",
},
usageWarningEmail: {
organizationName,
percentUsed: 80,
quotas: quotas.map((quota) => ({ ...quota, used: Math.round(quota.included * 0.8) })),
usageUrl: "https://accounts.locusgraph.com/usage",
},
usageOverPlanEmail: {
organizationName,
quotas,
usageUrl: "https://accounts.locusgraph.com/usage",
},
usageSummaryEmail: {
organizationName,
periodLabel: "October 2026",
rows: [
["Memory writes", "124,000"],
["Graphs", "6"],
["Credits spent", "$18.40"],
] as [string, string][],
usageUrl: "https://accounts.locusgraph.com/usage",
},
};
Loading
Loading