From f7596497d0d0cee082d81b7b505f7f7c6315bdc4 Mon Sep 17 00:00:00 2001 From: Nasim Akhtar Date: Tue, 3 Nov 2026 19:32:09 +0600 Subject: [PATCH 1/2] feat(email): product mark in the header, spendgraph branding and ten new notice templates --- apps/design/public/email/locusgraph-mark.png | Bin 0 -> 3897 bytes apps/design/public/email/spendgraph-mark.png | Bin 0 -> 3897 bytes .../src/demos/brand/media/email.demo.tsx | 39 +++++- .../demos/brand/media/email/email-frame.tsx | 9 +- .../demos/brand/media/email/sample-props.ts | 87 ++++++++++++ .../src/demos/brand/media/email/samples.ts | 127 ++++++------------ packages/email/src/index.ts | 25 +++- packages/email/src/kit/brand.ts | 28 ++++ packages/email/src/kit/email-layout.tsx | 40 +++++- packages/email/src/kit/words.ts | 3 + .../templates/{ => billing}/low-credits.tsx | 11 +- .../src/templates/{ => billing}/receipt.tsx | 11 +- .../email/src/templates/keys/key-created.tsx | 50 +++++++ .../email/src/templates/keys/key-expiring.tsx | 44 ++++++ .../email/src/templates/keys/key-revoked.tsx | 47 +++++++ .../src/templates/members/member-removed.tsx | 37 +++++ .../src/templates/members/role-changed.tsx | 38 ++++++ .../src/templates/org/invitation-accepted.tsx | 39 ++++++ .../src/templates/{ => org}/invitation.tsx | 22 +-- .../templates/org/organization-deleted.tsx | 45 +++++++ .../templates/org/ownership-transferred.tsx | 50 +++++++ .../email/src/templates/usage/quota-rows.ts | 16 +++ .../templates/{ => usage}/usage-over-plan.tsx | 29 ++-- .../src/templates/usage/usage-summary.tsx | 42 ++++++ .../src/templates/usage/usage-warning.tsx | 40 ++++++ packages/email/src/tests/brand.test.tsx | 38 ++++++ packages/email/src/tests/notices.test.tsx | 85 ++++++++++++ 27 files changed, 863 insertions(+), 139 deletions(-) create mode 100644 apps/design/public/email/locusgraph-mark.png create mode 100644 apps/design/public/email/spendgraph-mark.png create mode 100644 apps/design/src/demos/brand/media/email/sample-props.ts create mode 100644 packages/email/src/kit/brand.ts create mode 100644 packages/email/src/kit/words.ts rename packages/email/src/templates/{ => billing}/low-credits.tsx (83%) rename packages/email/src/templates/{ => billing}/receipt.tsx (81%) create mode 100644 packages/email/src/templates/keys/key-created.tsx create mode 100644 packages/email/src/templates/keys/key-expiring.tsx create mode 100644 packages/email/src/templates/keys/key-revoked.tsx create mode 100644 packages/email/src/templates/members/member-removed.tsx create mode 100644 packages/email/src/templates/members/role-changed.tsx create mode 100644 packages/email/src/templates/org/invitation-accepted.tsx rename packages/email/src/templates/{ => org}/invitation.tsx (65%) create mode 100644 packages/email/src/templates/org/organization-deleted.tsx create mode 100644 packages/email/src/templates/org/ownership-transferred.tsx create mode 100644 packages/email/src/templates/usage/quota-rows.ts rename packages/email/src/templates/{ => usage}/usage-over-plan.tsx (57%) create mode 100644 packages/email/src/templates/usage/usage-summary.tsx create mode 100644 packages/email/src/templates/usage/usage-warning.tsx create mode 100644 packages/email/src/tests/brand.test.tsx create mode 100644 packages/email/src/tests/notices.test.tsx diff --git a/apps/design/public/email/locusgraph-mark.png b/apps/design/public/email/locusgraph-mark.png new file mode 100644 index 0000000000000000000000000000000000000000..faaa426385b378ea4606cad83188933b5dc83015 GIT binary patch literal 3897 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 Hu0mjfDr8mM literal 0 HcmV?d00001 diff --git a/apps/design/public/email/spendgraph-mark.png b/apps/design/public/email/spendgraph-mark.png new file mode 100644 index 0000000000000000000000000000000000000000..faaa426385b378ea4606cad83188933b5dc83015 GIT binary patch literal 3897 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 Hu0mjfDr8mM literal 0 HcmV?d00001 diff --git a/apps/design/src/demos/brand/media/email.demo.tsx b/apps/design/src/demos/brand/media/email.demo.tsx index c603e22c..aa3aaf77 100644 --- a/apps/design/src/demos/brand/media/email.demo.tsx +++ b/apps/design/src/demos/brand/media/email.demo.tsx @@ -5,6 +5,7 @@ import { EmailLayout, EmailRows, EmailText, + emailProducts, lowCreditsEmail, } from "@locusgraph/email"; import { defineDemo, select } from "../../../registry/define-demo"; @@ -47,16 +48,44 @@ 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 }) => , - 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. Each template returns a subject, HTML and a plain text part.", + controls: { + template: select(emailTemplates, "invitation"), + product: select(emailProducts, "locusgraph"), + }, + playground: ({ template, product }) => ( + + ), + code: ({ template, product }) => templateCode(template, product), props: [ { 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: "renderEmail", type: "(subject: string, email: ReactElement) => RenderedEmail" }, { name: "RenderedEmail", type: "{ subject: string; html: string; text: string }" }, ], diff --git a/apps/design/src/demos/brand/media/email/email-frame.tsx b/apps/design/src/demos/brand/media/email/email-frame.tsx index 0468281c..91a0ddc4 100644 --- a/apps/design/src/demos/brand/media/email/email-frame.tsx +++ b/apps/design/src/demos/brand/media/email/email-frame.tsx @@ -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) { @@ -10,6 +10,11 @@ function fitFrameToEmail(event: SyntheticEvent) { 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 (
@@ -19,7 +24,7 @@ export function EmailFrame({ email }: { email: RenderedEmail }) {