mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(emails): align the email design tokens with the platform design system (#6479)
* improvement(emails): align the email design tokens with the platform design system * improvement(emails): preview every template in a single gallery page * improvement(emails): align font stack, logo and social-icon sizing with the platform * improvement(emails): tokenize the CTA and footnote, and enforce the platform mirror with a test * fix(emails): restore row spacing in the payment-failed details box * fix(emails): restore row spacing in the batch-invitation workspace list
This commit is contained in:
@@ -1,12 +1,17 @@
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
renderAbandonedCheckoutEmail,
|
||||
renderBatchInvitationEmail,
|
||||
renderCreditPurchaseEmail,
|
||||
renderCreditsExhaustedEmail,
|
||||
renderEnterpriseSubscriptionEmail,
|
||||
renderExistingAccountEmail,
|
||||
renderFreeTierUpgradeEmail,
|
||||
renderHelpConfirmationEmail,
|
||||
renderInvitationEmail,
|
||||
renderLimitThresholdEmail,
|
||||
renderOnboardingFollowupEmail,
|
||||
renderOTPEmail,
|
||||
renderPasswordResetEmail,
|
||||
renderPaymentFailedEmail,
|
||||
@@ -15,8 +20,10 @@ import {
|
||||
renderUsageLimitReachedEmail,
|
||||
renderUsageThresholdEmail,
|
||||
renderWelcomeEmail,
|
||||
renderWorkspaceAddedEmail,
|
||||
renderWorkspaceInvitationEmail,
|
||||
} from '@/components/emails'
|
||||
import { colors, typography } from '@/components/emails/_styles'
|
||||
import { emailPreviewQuerySchema } from '@/lib/api/contracts/common'
|
||||
import { validationErrorResponse } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
@@ -24,11 +31,16 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
const emailTemplates = {
|
||||
// Auth emails
|
||||
otp: () => renderOTPEmail('123456', 'user@example.com', 'email-verification'),
|
||||
'otp-sign-in': () => renderOTPEmail('123456', 'user@example.com', 'sign-in'),
|
||||
'reset-password': () => renderPasswordResetEmail('John', 'https://sim.ai/reset?token=abc123'),
|
||||
'existing-account': () => renderExistingAccountEmail('John'),
|
||||
welcome: () => renderWelcomeEmail('John'),
|
||||
'onboarding-followup': () => renderOnboardingFollowupEmail('John'),
|
||||
|
||||
// Invitation emails
|
||||
invitation: () => renderInvitationEmail('Jane Doe', 'Acme Corp', 'https://sim.ai/invite/abc123'),
|
||||
'workspace-added': () =>
|
||||
renderWorkspaceAddedEmail('Jane Doe', 'Engineering', 'https://sim.ai/workspace/ws_123'),
|
||||
'batch-invitation': () =>
|
||||
renderBatchInvitationEmail(
|
||||
'Jane Doe',
|
||||
@@ -87,6 +99,43 @@ const emailTemplates = {
|
||||
amount: 50,
|
||||
newBalance: 75,
|
||||
}),
|
||||
'credits-exhausted': () =>
|
||||
renderCreditsExhaustedEmail({
|
||||
userName: 'John',
|
||||
limit: 10,
|
||||
upgradeLink: 'https://sim.ai/settings/billing',
|
||||
}),
|
||||
'abandoned-checkout': () => renderAbandonedCheckoutEmail('John'),
|
||||
'limit-threshold-storage-warning': () =>
|
||||
renderLimitThresholdEmail({
|
||||
kind: 'warning',
|
||||
reason: 'storage',
|
||||
userName: 'John',
|
||||
usageLabel: '4.2 GB',
|
||||
limitLabel: '5 GB',
|
||||
percentUsed: 84,
|
||||
upgradeLink: 'https://sim.ai/settings/billing',
|
||||
}),
|
||||
'limit-threshold-tables-reached': () =>
|
||||
renderLimitThresholdEmail({
|
||||
kind: 'reached',
|
||||
reason: 'tables',
|
||||
userName: 'John',
|
||||
usageLabel: '50,000 rows',
|
||||
limitLabel: '50,000 rows',
|
||||
percentUsed: 100,
|
||||
upgradeLink: 'https://sim.ai/settings/billing',
|
||||
}),
|
||||
'limit-threshold-seats-reached': () =>
|
||||
renderLimitThresholdEmail({
|
||||
kind: 'reached',
|
||||
reason: 'seats',
|
||||
userName: 'John',
|
||||
usageLabel: '10 seats',
|
||||
limitLabel: '10 seats',
|
||||
percentUsed: 100,
|
||||
upgradeLink: 'https://sim.ai/settings/billing',
|
||||
}),
|
||||
'payment-failed': () =>
|
||||
renderPaymentFailedEmail({
|
||||
userName: 'John',
|
||||
@@ -138,6 +187,40 @@ function isEmailTemplate(template: string): template is EmailTemplate {
|
||||
return template in emailTemplates
|
||||
}
|
||||
|
||||
const CATEGORIZED = {
|
||||
Auth: ['otp', 'otp-sign-in', 'reset-password', 'existing-account', 'welcome'],
|
||||
Invitations: ['invitation', 'batch-invitation', 'workspace-invitation', 'workspace-added'],
|
||||
Support: ['help-confirmation'],
|
||||
Billing: [
|
||||
'usage-threshold',
|
||||
'usage-limit-reached',
|
||||
'usage-limit-reached-org',
|
||||
'free-tier-upgrade',
|
||||
'credits-exhausted',
|
||||
'limit-threshold-storage-warning',
|
||||
'limit-threshold-tables-reached',
|
||||
'limit-threshold-seats-reached',
|
||||
'payment-failed',
|
||||
'credit-purchase',
|
||||
'plan-welcome-pro',
|
||||
'plan-welcome-team',
|
||||
'enterprise-subscription',
|
||||
],
|
||||
Notifications: ['schedule-disabled', 'schedule-disabled-auth'],
|
||||
'Plain (unbranded)': ['onboarding-followup', 'abandoned-checkout'],
|
||||
} satisfies Record<string, EmailTemplate[]>
|
||||
|
||||
/**
|
||||
* Category map for the gallery, with any template missing from {@link CATEGORIZED}
|
||||
* appended rather than dropped — so a newly registered template always shows up
|
||||
* even if nobody remembers to file it.
|
||||
*/
|
||||
const PREVIEW_CATEGORIES: Record<string, EmailTemplate[]> = (() => {
|
||||
const filed = new Set<string>(Object.values(CATEGORIZED).flat())
|
||||
const unfiled = (Object.keys(emailTemplates) as EmailTemplate[]).filter((t) => !filed.has(t))
|
||||
return unfiled.length > 0 ? { ...CATEGORIZED, Uncategorized: unfiled } : CATEGORIZED
|
||||
})()
|
||||
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const queryValidation = emailPreviewQuerySchema.safeParse(
|
||||
@@ -147,32 +230,23 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const { template } = queryValidation.data
|
||||
|
||||
if (!template) {
|
||||
const categories = {
|
||||
Auth: ['otp', 'reset-password', 'welcome'],
|
||||
Invitations: ['invitation', 'batch-invitation', 'workspace-invitation'],
|
||||
Support: ['help-confirmation'],
|
||||
Billing: [
|
||||
'usage-threshold',
|
||||
'enterprise-subscription',
|
||||
'free-tier-upgrade',
|
||||
'plan-welcome-pro',
|
||||
'plan-welcome-team',
|
||||
'credit-purchase',
|
||||
'payment-failed',
|
||||
'usage-limit-reached',
|
||||
'usage-limit-reached-org',
|
||||
],
|
||||
Notifications: ['schedule-disabled', 'schedule-disabled-auth'],
|
||||
}
|
||||
|
||||
const categoryHtml = Object.entries(categories)
|
||||
const categoryHtml = Object.entries(PREVIEW_CATEGORIES)
|
||||
.map(
|
||||
([category, templates]) => `
|
||||
<h2 style="margin-top: 24px; margin-bottom: 12px; font-size: 14px; color: #666; text-transform: uppercase; letter-spacing: 0.5px;">${category}</h2>
|
||||
<ul style="list-style: none; padding: 0; margin: 0;">
|
||||
${templates.map((t) => `<li style="margin: 8px 0;"><a href="?template=${t}" style="color: #33C482; text-decoration: none; font-size: 16px;">${t}</a></li>`).join('')}
|
||||
</ul>
|
||||
`
|
||||
<section>
|
||||
<h2>${category}</h2>
|
||||
<div class="grid">
|
||||
${templates
|
||||
.map(
|
||||
(t) => `
|
||||
<figure>
|
||||
<figcaption><span>${t}</span><a href="?template=${t}" target="_blank" rel="noreferrer">open ↗</a></figcaption>
|
||||
<iframe src="?template=${t}" title="${t}" loading="lazy"></iframe>
|
||||
</figure>`
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
</section>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
@@ -180,15 +254,29 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Email Previews</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Email Templates</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; }
|
||||
h1 { color: #333; margin-bottom: 32px; }
|
||||
a:hover { text-decoration: underline; }
|
||||
:root { color-scheme: light; }
|
||||
body { font-family: ${typography.systemFontFamily}; margin: 0; padding: 40px 24px 80px; background: ${colors.bgCard}; color: ${colors.textPrimary}; }
|
||||
h1 { font-size: 24px; font-weight: 600; margin: 0 0 4px; }
|
||||
.count { color: ${colors.textMuted}; font-size: 14px; margin: 0 0 40px; }
|
||||
h2 { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: ${colors.textMuted}; margin: 48px 0 16px; padding-bottom: 8px; border-bottom: 1px solid ${colors.border}; }
|
||||
section { max-width: 1400px; margin: 0 auto; }
|
||||
section > h2:first-child { margin-top: 0; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(640px, 1fr)); gap: 32px; }
|
||||
figure { margin: 0 0 32px; }
|
||||
figcaption { display: flex; justify-content: space-between; align-items: baseline; font-size: 13px; margin-bottom: 8px; }
|
||||
figcaption span { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: ${colors.textBody}; }
|
||||
figcaption a { color: ${colors.textMuted}; text-decoration: none; font-size: 12px; }
|
||||
figcaption a:hover { color: ${colors.textPrimary}; }
|
||||
iframe { width: 100%; height: 900px; border: 1px solid ${colors.border}; border-radius: 8px; background: ${colors.bgCard}; display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Email Templates</h1>
|
||||
<p class="count">Every email Sim sends — ${Object.keys(emailTemplates).length} previews.</p>
|
||||
${categoryHtml}
|
||||
</body>
|
||||
</html>`,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Email styles cannot use CSS variables — clients strip them — so `base.ts`
|
||||
* hardcodes hex copies of the platform tokens. Nothing else detects it when
|
||||
* `globals.css`, `tailwind.config.ts`, or the chip chrome moves and the copies
|
||||
* go stale, which is exactly how they drifted before. This suite is that
|
||||
* detector.
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { baseStyles, colors, typography } from '@/components/emails/_styles'
|
||||
|
||||
const APP_ROOT = join(__dirname, '../../..')
|
||||
|
||||
const globalsCss = readFileSync(join(APP_ROOT, 'app/_styles/globals.css'), 'utf8')
|
||||
const tailwindConfig = readFileSync(join(APP_ROOT, 'tailwind.config.ts'), 'utf8')
|
||||
const chipChrome = readFileSync(
|
||||
join(APP_ROOT, '../../packages/emcn/src/components/chip/chip-chrome.ts'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
/**
|
||||
* The light-mode `:root` block. Dark mode redefines the same names later in the
|
||||
* file, and emails are light-only, so the FIRST definition is the one to read.
|
||||
*/
|
||||
function readCssVar(name: string): string {
|
||||
const match = globalsCss.match(new RegExp(`--${name}:\\s*([^;]+);`))
|
||||
if (!match) throw new Error(`--${name} not found in globals.css`)
|
||||
return match[1].trim()
|
||||
}
|
||||
|
||||
function readTailwindFontSize(name: string): string {
|
||||
const match = tailwindConfig.match(new RegExp(`\\b${name}:\\s*'([^']+)'`))
|
||||
if (!match) throw new Error(`fontSize.${name} not found in tailwind.config.ts`)
|
||||
return match[1]
|
||||
}
|
||||
|
||||
/** Every email color token and the platform variable it copies. */
|
||||
const COLOR_MIRROR: Record<string, string> = {
|
||||
bgOuter: 'surface-1',
|
||||
bgCard: 'surface-2',
|
||||
surfaceSubtle: 'surface-3',
|
||||
textPrimary: 'text-primary',
|
||||
textBody: 'text-body',
|
||||
textMuted: 'text-muted',
|
||||
textInverse: 'text-inverse',
|
||||
border: 'border',
|
||||
errorBg: 'terminal-status-error-bg',
|
||||
errorBorder: 'error-muted',
|
||||
footerBg: 'surface-1',
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokens with no single CSS variable behind them. Each needs a stated reason —
|
||||
* an entry here is a deliberate exception, not an oversight.
|
||||
*/
|
||||
const UNMIRRORED_COLORS: Record<string, string> = {
|
||||
brandTertiary: 'Runtime-conditional on getBrandConfig(); neutral default equals --text-primary.',
|
||||
}
|
||||
|
||||
describe('email color tokens mirror globals.css', () => {
|
||||
for (const [token, cssVar] of Object.entries(COLOR_MIRROR)) {
|
||||
it(`colors.${token} equals --${cssVar}`, () => {
|
||||
expect(colors[token as keyof typeof colors]).toBe(readCssVar(cssVar))
|
||||
})
|
||||
}
|
||||
|
||||
it('every color token is either mirrored or has a written exemption', () => {
|
||||
const accounted = new Set([...Object.keys(COLOR_MIRROR), ...Object.keys(UNMIRRORED_COLORS)])
|
||||
const unaccounted = Object.keys(colors).filter((key) => !accounted.has(key))
|
||||
expect(unaccounted).toEqual([])
|
||||
})
|
||||
|
||||
it('exemptions state a reason', () => {
|
||||
for (const reason of Object.values(UNMIRRORED_COLORS)) {
|
||||
expect(reason.trim().length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('email type scale mirrors tailwind.config.ts', () => {
|
||||
it.each(['caption', 'base', 'md'])('fontSize.%s matches the Tailwind token', (name) => {
|
||||
expect(typography.fontSize[name as 'caption' | 'base' | 'md']).toBe(readTailwindFontSize(name))
|
||||
})
|
||||
|
||||
it('sm is Tailwind stock 14px — the size text-sm resolves to in chip chrome', () => {
|
||||
expect(typography.fontSize.sm).toBe('14px')
|
||||
expect(chipChrome).toContain('text-sm')
|
||||
})
|
||||
|
||||
it('display is deliberately off-scale (no platform headline-numeral token)', () => {
|
||||
expect(typography.fontSize.display).toBe('24px')
|
||||
expect(tailwindConfig).not.toContain("'24px'")
|
||||
})
|
||||
})
|
||||
|
||||
describe('email geometry mirrors the platform', () => {
|
||||
it('the card radius equals --radius', () => {
|
||||
// --radius is authored in rem; emails need px.
|
||||
expect(readCssVar('radius')).toBe('0.5rem')
|
||||
expect(baseStyles.container.borderRadius).toBe('8px')
|
||||
})
|
||||
|
||||
it('the CTA transcribes chipGeometryClass', () => {
|
||||
const geometry = chipChrome.match(/chipGeometryClass = `([^`]+)`/)?.[1]
|
||||
expect(geometry).toBeDefined()
|
||||
expect(geometry).toContain('h-[30px]')
|
||||
expect(geometry).toContain('rounded-lg')
|
||||
expect(geometry).toContain('px-2')
|
||||
expect(geometry).toContain('text-sm')
|
||||
|
||||
expect(baseStyles.button.lineHeight).toBe('30px')
|
||||
expect(baseStyles.button.borderRadius).toBe('8px')
|
||||
expect(baseStyles.button.padding).toBe('0 8px')
|
||||
expect(baseStyles.button.fontSize).toBe(typography.fontSize.sm)
|
||||
})
|
||||
})
|
||||
|
||||
describe('email font weights stay on the platform scale', () => {
|
||||
it('no token uses a weight outside 400/500/600', () => {
|
||||
const offScale = Object.entries(baseStyles).filter(([, style]) => {
|
||||
const weight = (style as { fontWeight?: unknown }).fontWeight
|
||||
return weight !== undefined && ![400, 500, 600].includes(weight as number)
|
||||
})
|
||||
expect(offScale.map(([name]) => name)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -13,46 +13,70 @@ function buildColors() {
|
||||
isWhitelabeled && brand.theme?.primaryColor ? brand.theme.primaryColor : '#1a1a1a'
|
||||
|
||||
return {
|
||||
/** Main canvas background — a hair off-white so the white card reads via contrast, not the border alone */
|
||||
bgOuter: '#f8f8f8',
|
||||
/** Canvas behind the card — platform `--surface-1` (the sidebar/panel surface) */
|
||||
bgOuter: '#fbfbfb',
|
||||
/** Card/container background — platform `--surface-2` */
|
||||
bgCard: '#ffffff',
|
||||
/** Primary text — platform `--text-primary` */
|
||||
/** Headings and emphasis — platform `--text-primary` */
|
||||
textPrimary: '#1a1a1a',
|
||||
/** Secondary text — platform `--text-secondary` */
|
||||
textSecondary: '#525252',
|
||||
/** Tertiary text — platform `--text-tertiary` */
|
||||
textTertiary: '#5c5c5c',
|
||||
/** Muted text (footer) — platform `--text-muted` */
|
||||
textMuted: '#707070',
|
||||
/** Brand primary — neutral by default, brand color when whitelabeled */
|
||||
brandPrimary:
|
||||
isWhitelabeled && brand.theme?.primaryColor ? brand.theme.primaryColor : '#1a1a1a',
|
||||
/** Body and value text — platform `--text-body` */
|
||||
textBody: '#434343',
|
||||
/** Muted text (labels, footer) — platform `--text-muted` */
|
||||
textMuted: '#7a7a7a',
|
||||
/** Accent for buttons and links — neutral by default, brand color when whitelabeled */
|
||||
brandTertiary: accentColor,
|
||||
/** Border/divider — platform `--border` */
|
||||
divider: '#dedede',
|
||||
/** Subtle fill for info/code boxes on the white card */
|
||||
/** Borders and dividers — platform `--border` */
|
||||
border: '#d8d8d8',
|
||||
/** Fill for info/code boxes on the white card — platform `--surface-3` */
|
||||
surfaceSubtle: '#f7f7f7',
|
||||
/** Error surface fill — platform `--terminal-status-error-bg` */
|
||||
errorBg: '#fef2f2',
|
||||
/** Error surface border — platform `--error-muted` */
|
||||
errorBorder: '#fecaca',
|
||||
/** Text on an inverse (dark) fill, e.g. the CTA — platform `--text-inverse` */
|
||||
textInverse: '#ffffff',
|
||||
/** Footer background — matches the canvas */
|
||||
footerBg: '#f8f8f8',
|
||||
footerBg: '#fbfbfb',
|
||||
}
|
||||
}
|
||||
|
||||
export const colors = buildColors()
|
||||
|
||||
/** Typography settings */
|
||||
/**
|
||||
* Typography settings. Enforced against the platform sources by
|
||||
* `base.tokens.test.ts`.
|
||||
*/
|
||||
export const typography = {
|
||||
/**
|
||||
* Mirrors the platform face and its fallback chain
|
||||
* (`apps/sim/app/_styles/fonts/season/season.ts`). This matters more than the
|
||||
* `<Font>` webfont in `EmailLayout` — Gmail and Outlook strip `@font-face`
|
||||
* entirely, so for most recipients the fallback chain IS the rendered font.
|
||||
*/
|
||||
fontFamily:
|
||||
"'Season Sans', -apple-system, 'SF Pro Display', 'SF Pro Text', 'Helvetica', sans-serif",
|
||||
"'Season Sans', system-ui, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif",
|
||||
/**
|
||||
* Deliberately brand-free, for the plain personal emails — those read as a
|
||||
* message typed by a person, so they must NOT carry the brand face.
|
||||
*/
|
||||
systemFontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||
/**
|
||||
* `caption`/`base`/`md` are Sim's own scale from `tailwind.config.ts`. `sm` is
|
||||
* Tailwind's stock 14px — not a Sim token, but what `text-sm` resolves to in
|
||||
* `chipGeometryClass`, so the CTA has to use it.
|
||||
*/
|
||||
fontSize: {
|
||||
body: '16px',
|
||||
small: '14px',
|
||||
caption: '12px',
|
||||
sm: '14px',
|
||||
base: '15px',
|
||||
/** Email body copy. Larger than the app's 15px `base` — the client default. */
|
||||
md: '16px',
|
||||
/**
|
||||
* Display figure (OTP code, credit balance). Deliberately above the platform
|
||||
* scale — the app has no headline-numeral token because it has no surface
|
||||
* that needs one.
|
||||
*/
|
||||
display: '24px',
|
||||
},
|
||||
lineHeight: {
|
||||
body: '24px',
|
||||
@@ -60,19 +84,43 @@ export const typography = {
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Weight scale. The platform allows exactly three steps (400/500/600) — never
|
||||
* `bold`, which resolves to 700 and sits off the scale.
|
||||
*/
|
||||
export const fontWeight = {
|
||||
normal: 400,
|
||||
medium: 500,
|
||||
semibold: 600,
|
||||
} as const
|
||||
|
||||
/** Platform `--radius` (`rounded-lg`) — the single radius the design system uses. */
|
||||
const RADIUS = '8px'
|
||||
|
||||
/** Spacing values */
|
||||
export const spacing = {
|
||||
containerWidth: 600,
|
||||
gutter: 40,
|
||||
sectionGap: 20,
|
||||
paragraphGap: 12,
|
||||
/** Logo width in pixels */
|
||||
logoWidth: 90,
|
||||
}
|
||||
|
||||
/** Shared body-copy ramp. {@link baseStyles.paragraph} and `greeting` differ only in margin. */
|
||||
const bodyText = {
|
||||
fontSize: typography.fontSize.md,
|
||||
lineHeight: typography.lineHeight.body,
|
||||
color: colors.textBody,
|
||||
fontWeight: fontWeight.normal,
|
||||
fontFamily: typography.fontFamily,
|
||||
}
|
||||
|
||||
/** Shared box geometry. {@link baseStyles.infoBox} and `errorBox` differ only in fill. */
|
||||
const boxGeometry = {
|
||||
padding: '16px 18px',
|
||||
borderRadius: RADIUS,
|
||||
margin: '16px 0',
|
||||
}
|
||||
|
||||
export const baseStyles = {
|
||||
fontFamily: typography.fontFamily,
|
||||
|
||||
/** Main body wrapper with outer background */
|
||||
main: {
|
||||
backgroundColor: colors.bgOuter,
|
||||
@@ -80,19 +128,13 @@ export const baseStyles = {
|
||||
padding: '32px 0',
|
||||
},
|
||||
|
||||
/** Center wrapper for email content */
|
||||
wrapper: {
|
||||
maxWidth: `${spacing.containerWidth}px`,
|
||||
margin: '0 auto',
|
||||
},
|
||||
|
||||
/** Main card container — white surface, chip-radius, hairline border on the near-white canvas */
|
||||
container: {
|
||||
maxWidth: `${spacing.containerWidth}px`,
|
||||
margin: '0 auto',
|
||||
backgroundColor: colors.bgCard,
|
||||
border: `1px solid ${colors.divider}`,
|
||||
borderRadius: '8px',
|
||||
border: `1px solid ${colors.border}`,
|
||||
borderRadius: RADIUS,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
@@ -109,35 +151,33 @@ export const baseStyles = {
|
||||
|
||||
/** Standard paragraph text */
|
||||
paragraph: {
|
||||
fontSize: typography.fontSize.body,
|
||||
lineHeight: typography.lineHeight.body,
|
||||
color: colors.textSecondary,
|
||||
fontWeight: 400,
|
||||
fontFamily: typography.fontFamily,
|
||||
...bodyText,
|
||||
margin: `${spacing.paragraphGap}px 0`,
|
||||
},
|
||||
|
||||
/** Bold label text (e.g., "Platform:", "Time:") */
|
||||
label: {
|
||||
fontSize: typography.fontSize.body,
|
||||
lineHeight: typography.lineHeight.body,
|
||||
color: colors.textSecondary,
|
||||
fontWeight: 'bold' as const,
|
||||
fontFamily: typography.fontFamily,
|
||||
margin: 0,
|
||||
display: 'inline',
|
||||
/**
|
||||
* The opening line of an email body — flush to the top, so it sits a fixed
|
||||
* distance below the logo instead of inheriting the paragraph gap.
|
||||
*/
|
||||
greeting: {
|
||||
...bodyText,
|
||||
margin: `0 0 ${spacing.paragraphGap}px 0`,
|
||||
},
|
||||
|
||||
/** Primary CTA button - matches the platform's primary Chip (inverse fill, rounded-lg, h-30, text-sm) */
|
||||
/**
|
||||
* Primary CTA — the platform's primary Chip, transcribed for email:
|
||||
* `chipPrimaryFillTokens` fill (`--text-primary`), `chipGeometryClass`
|
||||
* geometry (`h-[30px]`, `rounded-lg`, `px-2`, `text-sm`) at normal weight.
|
||||
*/
|
||||
button: {
|
||||
display: 'inline-block',
|
||||
backgroundColor: colors.brandTertiary,
|
||||
color: '#ffffff',
|
||||
fontWeight: 400,
|
||||
fontSize: '14px',
|
||||
color: colors.textInverse,
|
||||
fontWeight: fontWeight.normal,
|
||||
fontSize: typography.fontSize.sm,
|
||||
lineHeight: '30px',
|
||||
padding: '0 12px',
|
||||
borderRadius: '8px',
|
||||
padding: '0 8px',
|
||||
borderRadius: RADIUS,
|
||||
textDecoration: 'none',
|
||||
textAlign: 'center' as const,
|
||||
margin: '4px 0',
|
||||
@@ -147,25 +187,28 @@ export const baseStyles = {
|
||||
/** Link text style - neutral color, so it carries an underline to read as a link */
|
||||
link: {
|
||||
color: colors.brandTertiary,
|
||||
fontWeight: 400,
|
||||
fontWeight: fontWeight.normal,
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
|
||||
/** Horizontal divider */
|
||||
divider: {
|
||||
borderTop: `1px solid ${colors.divider}`,
|
||||
borderTop: `1px solid ${colors.border}`,
|
||||
margin: `16px 0`,
|
||||
},
|
||||
|
||||
/** Footer container (inside gray area below card) */
|
||||
footer: {
|
||||
maxWidth: `${spacing.containerWidth}px`,
|
||||
margin: '0 auto',
|
||||
padding: `32px ${spacing.gutter}px`,
|
||||
textAlign: 'left' as const,
|
||||
/**
|
||||
* Footer link — muted rather than {@link link}'s accent, since the footer sits
|
||||
* outside the card and its links are secondary to the message.
|
||||
*/
|
||||
footerLink: {
|
||||
color: colors.textMuted,
|
||||
fontWeight: fontWeight.normal,
|
||||
textDecoration: 'underline',
|
||||
fontFamily: typography.fontFamily,
|
||||
},
|
||||
|
||||
/** Footer text style */
|
||||
/** Footer text style — used inside the footer's own centered table cells */
|
||||
footerText: {
|
||||
fontSize: typography.fontSize.caption,
|
||||
lineHeight: typography.lineHeight.caption,
|
||||
@@ -174,78 +217,96 @@ export const baseStyles = {
|
||||
margin: '0 0 10px 0',
|
||||
},
|
||||
|
||||
/**
|
||||
* The closing fine-print line inside the card (who this was sent to, when it
|
||||
* fires again). Same ramp as {@link footerText}, but left-aligned — the card
|
||||
* is left-aligned while the footer's cells are not. Every template spelled
|
||||
* this out as a spread override; use the token.
|
||||
*/
|
||||
footnote: {
|
||||
fontSize: typography.fontSize.caption,
|
||||
lineHeight: typography.lineHeight.caption,
|
||||
color: colors.textMuted,
|
||||
fontFamily: typography.fontFamily,
|
||||
margin: '0 0 10px 0',
|
||||
textAlign: 'left' as const,
|
||||
},
|
||||
|
||||
/** Code/OTP container */
|
||||
codeContainer: {
|
||||
margin: '12px 0',
|
||||
padding: '12px 16px',
|
||||
backgroundColor: colors.surfaceSubtle,
|
||||
borderRadius: '8px',
|
||||
border: `1px solid ${colors.divider}`,
|
||||
borderRadius: RADIUS,
|
||||
border: `1px solid ${colors.border}`,
|
||||
textAlign: 'center' as const,
|
||||
},
|
||||
|
||||
/** Code/OTP text */
|
||||
code: {
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold' as const,
|
||||
fontSize: typography.fontSize.display,
|
||||
fontWeight: fontWeight.semibold,
|
||||
letterSpacing: '3px',
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.fontFamily,
|
||||
margin: 0,
|
||||
},
|
||||
|
||||
/** Code block text (for JSON/code display) */
|
||||
codeBlock: {
|
||||
fontSize: typography.fontSize.caption,
|
||||
lineHeight: typography.lineHeight.caption,
|
||||
color: colors.textSecondary,
|
||||
fontFamily: 'monospace',
|
||||
whiteSpace: 'pre-wrap' as const,
|
||||
wordWrap: 'break-word' as const,
|
||||
margin: 0,
|
||||
},
|
||||
|
||||
/** Highlighted info box (e.g., "What you get with Pro") */
|
||||
infoBox: {
|
||||
...boxGeometry,
|
||||
backgroundColor: colors.surfaceSubtle,
|
||||
padding: '16px 18px',
|
||||
borderRadius: '8px',
|
||||
margin: '16px 0',
|
||||
},
|
||||
|
||||
/** Error-state variant of {@link infoBox} */
|
||||
errorBox: {
|
||||
...boxGeometry,
|
||||
backgroundColor: colors.errorBg,
|
||||
border: `1px solid ${colors.errorBorder}`,
|
||||
},
|
||||
|
||||
/** Info box title */
|
||||
infoBoxTitle: {
|
||||
fontSize: typography.fontSize.body,
|
||||
fontSize: typography.fontSize.md,
|
||||
lineHeight: typography.lineHeight.body,
|
||||
fontWeight: 600,
|
||||
fontWeight: fontWeight.semibold,
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.fontFamily,
|
||||
margin: '0 0 8px 0',
|
||||
},
|
||||
|
||||
/** Info box list content */
|
||||
/**
|
||||
* Info box body copy.
|
||||
*
|
||||
* `margin: 0` — so multi-row content must be ONE `Text` with `<br />` between
|
||||
* rows, never several `Text` nodes, which would stack flush with no gap.
|
||||
*/
|
||||
infoBoxList: {
|
||||
fontSize: typography.fontSize.body,
|
||||
fontSize: typography.fontSize.md,
|
||||
lineHeight: '1.6',
|
||||
color: colors.textSecondary,
|
||||
color: colors.textBody,
|
||||
fontFamily: typography.fontFamily,
|
||||
margin: 0,
|
||||
},
|
||||
|
||||
/** Section borders - decorative accent line */
|
||||
sectionsBorders: {
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
/** Muted caption inside an info box, above a {@link infoBoxValue} figure */
|
||||
infoBoxLabel: {
|
||||
fontSize: typography.fontSize.sm,
|
||||
lineHeight: typography.lineHeight.caption,
|
||||
color: colors.textMuted,
|
||||
fontWeight: fontWeight.normal,
|
||||
fontFamily: typography.fontFamily,
|
||||
margin: 0,
|
||||
},
|
||||
|
||||
sectionBorder: {
|
||||
borderBottom: `1px solid ${colors.divider}`,
|
||||
width: '249px',
|
||||
},
|
||||
|
||||
sectionCenter: {
|
||||
borderBottom: `1px solid ${colors.brandTertiary}`,
|
||||
width: '102px',
|
||||
/** The headline figure of a stat info box (e.g. a credit balance) */
|
||||
infoBoxValue: {
|
||||
fontSize: typography.fontSize.display,
|
||||
lineHeight: '32px',
|
||||
fontWeight: fontWeight.semibold,
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.fontFamily,
|
||||
margin: '4px 0 0 0',
|
||||
},
|
||||
|
||||
/** Spacer row for vertical spacing in tables */
|
||||
@@ -266,22 +327,13 @@ export const baseStyles = {
|
||||
lineHeight: '1px',
|
||||
width: `${spacing.gutter}px`,
|
||||
},
|
||||
|
||||
/** Info row (e.g., Platform, Device location, Time) */
|
||||
infoRow: {
|
||||
fontSize: typography.fontSize.body,
|
||||
lineHeight: typography.lineHeight.body,
|
||||
color: colors.textSecondary,
|
||||
fontFamily: typography.fontFamily,
|
||||
margin: '8px 0',
|
||||
},
|
||||
}
|
||||
|
||||
/** Styles for plain personal emails (no branding, no EmailLayout) */
|
||||
/** Styles for plain personal emails (no branding, no EmailLayout). */
|
||||
export const plainEmailStyles = {
|
||||
body: {
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||
backgroundColor: '#ffffff',
|
||||
fontFamily: typography.systemFontFamily,
|
||||
backgroundColor: colors.bgCard,
|
||||
margin: '0',
|
||||
padding: '0',
|
||||
},
|
||||
@@ -291,9 +343,9 @@ export const plainEmailStyles = {
|
||||
padding: '0 24px',
|
||||
},
|
||||
p: {
|
||||
fontSize: '15px',
|
||||
fontSize: typography.fontSize.base,
|
||||
lineHeight: '1.6',
|
||||
color: '#1a1a1a',
|
||||
color: colors.textPrimary,
|
||||
margin: '0 0 16px',
|
||||
},
|
||||
} as const
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { baseStyles, colors, plainEmailStyles, spacing, typography } from './base'
|
||||
export { baseStyles, colors, fontWeight, plainEmailStyles, spacing, typography } from './base'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link, Text } from '@react-email/components'
|
||||
import { Text } from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { EmailButton, EmailLayout } from '@/components/emails/components'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
@@ -23,20 +23,18 @@ export function ExistingAccountEmail({ username = '' }: ExistingAccountEmailProp
|
||||
preview={`Someone tried to sign up with your ${brand.name} email`}
|
||||
showUnsubscribe={false}
|
||||
>
|
||||
<Text style={baseStyles.paragraph}>Hello {username},</Text>
|
||||
<Text style={baseStyles.greeting}>Hello {username},</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Someone just tried to create a {brand.name} account using this email address, but an account
|
||||
already exists. If this was you, sign in instead — or reset your password if you've
|
||||
forgotten it.
|
||||
</Text>
|
||||
|
||||
<Link href={loginLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Sign In</Text>
|
||||
</Link>
|
||||
<EmailButton href={loginLink}>Sign In</EmailButton>
|
||||
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
<Text style={baseStyles.footnote}>
|
||||
If this wasn't you, no action is needed — no account was created or changed.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Body, Head, Html, Preview, Text } from '@react-email/components'
|
||||
import { plainEmailStyles as styles } from '@/components/emails/_styles'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
interface OnboardingFollowupEmailProps {
|
||||
userName?: string
|
||||
}
|
||||
|
||||
export function OnboardingFollowupEmail({ userName }: OnboardingFollowupEmailProps) {
|
||||
const brand = getBrandConfig()
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
@@ -14,7 +17,7 @@ export function OnboardingFollowupEmail({ userName }: OnboardingFollowupEmailPro
|
||||
<div style={styles.container}>
|
||||
<Text style={styles.p}>{userName ? `Hey ${userName},` : 'Hey,'}</Text>
|
||||
<Text style={styles.p}>
|
||||
It's been a few days since you signed up. I hope you're enjoying Sim!
|
||||
It's been a few days since you signed up. I hope you're enjoying {brand.name}!
|
||||
</Text>
|
||||
<Text style={styles.p}>
|
||||
I'd love to know — what did you expect when you signed up vs. what did you get?
|
||||
@@ -27,7 +30,7 @@ export function OnboardingFollowupEmail({ userName }: OnboardingFollowupEmailPro
|
||||
<br />
|
||||
Emir
|
||||
<br />
|
||||
Founder, Sim
|
||||
Founder, {brand.name}
|
||||
</Text>
|
||||
</div>
|
||||
</Body>
|
||||
|
||||
@@ -37,7 +37,7 @@ export function OTPVerificationEmail({
|
||||
|
||||
return (
|
||||
<EmailLayout preview={getSubjectByType(type, brand.name, chatTitle)} showUnsubscribe={false}>
|
||||
<Text style={baseStyles.paragraph}>Your verification code:</Text>
|
||||
<Text style={baseStyles.greeting}>Your verification code:</Text>
|
||||
|
||||
<Section style={baseStyles.codeContainer}>
|
||||
<Text style={baseStyles.code}>{otp}</Text>
|
||||
@@ -45,10 +45,9 @@ export function OTPVerificationEmail({
|
||||
|
||||
<Text style={baseStyles.paragraph}>This code will expire in 15 minutes.</Text>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
<Text style={baseStyles.footnote}>
|
||||
Do not share this code with anyone. If you didn't request this code, you can safely ignore
|
||||
this email.
|
||||
</Text>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link, Text } from '@react-email/components'
|
||||
import { Text } from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { EmailButton, EmailLayout } from '@/components/emails/components'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
interface ResetPasswordEmailProps {
|
||||
@@ -13,20 +13,17 @@ export function ResetPasswordEmail({ username = '', resetLink = '' }: ResetPassw
|
||||
|
||||
return (
|
||||
<EmailLayout preview={`Reset your ${brand.name} password`} showUnsubscribe={false}>
|
||||
<Text style={baseStyles.paragraph}>Hello {username},</Text>
|
||||
<Text style={baseStyles.greeting}>Hello {username},</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
A password reset was requested for your {brand.name} account. Click below to set a new
|
||||
password.
|
||||
</Text>
|
||||
|
||||
<Link href={resetLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Reset Password</Text>
|
||||
</Link>
|
||||
<EmailButton href={resetLink}>Reset Password</EmailButton>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
<Text style={baseStyles.footnote}>
|
||||
If you didn't request this, you can ignore this email. Link expires in 24 hours.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link, Text } from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { EmailButton, EmailLayout } from '@/components/emails/components'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
@@ -14,17 +14,13 @@ export function WelcomeEmail({ userName }: WelcomeEmailProps) {
|
||||
|
||||
return (
|
||||
<EmailLayout preview={`Welcome to ${brand.name}`} showUnsubscribe={false}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hey ${userName},` : 'Hey,'}
|
||||
</Text>
|
||||
<Text style={baseStyles.greeting}>{userName ? `Hey ${userName},` : 'Hey,'}</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Welcome to {brand.name}! Your account is ready. Start building, testing, and deploying AI
|
||||
workflows in minutes.
|
||||
</Text>
|
||||
|
||||
<Link href={`${baseUrl}/login`} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Get Started</Text>
|
||||
</Link>
|
||||
<EmailButton href={`${baseUrl}/login`}>Get Started</EmailButton>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
If you have any questions or feedback, just reply to this email. I read every message!
|
||||
@@ -40,10 +36,9 @@ export function WelcomeEmail({ userName }: WelcomeEmailProps) {
|
||||
|
||||
<Text style={baseStyles.paragraph}>- Emir, co-founder of {brand.name}</Text>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
<Text style={baseStyles.footnote}>
|
||||
You're on the Community plan with 1,000 credits to get started.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Body, Head, Html, Preview, Text } from '@react-email/components'
|
||||
import { plainEmailStyles as styles } from '@/components/emails/_styles'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
interface AbandonedCheckoutEmailProps {
|
||||
userName?: string
|
||||
}
|
||||
|
||||
export function AbandonedCheckoutEmail({ userName }: AbandonedCheckoutEmailProps) {
|
||||
const brand = getBrandConfig()
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
@@ -14,7 +17,8 @@ export function AbandonedCheckoutEmail({ userName }: AbandonedCheckoutEmailProps
|
||||
<div style={styles.container}>
|
||||
<Text style={styles.p}>{userName ? `Hi ${userName},` : 'Hi,'}</Text>
|
||||
<Text style={styles.p}>
|
||||
I saw that you tried to upgrade your Sim plan but didn't end up completing it.
|
||||
I saw that you tried to upgrade your {brand.name} plan but didn't end up completing
|
||||
it.
|
||||
</Text>
|
||||
<Text style={styles.p}>
|
||||
Did you run into an issue, or did you have a question? Here to help.
|
||||
@@ -22,7 +26,7 @@ export function AbandonedCheckoutEmail({ userName }: AbandonedCheckoutEmailProps
|
||||
<Text style={styles.p}>
|
||||
— Emir
|
||||
<br />
|
||||
Founder, Sim
|
||||
Founder, {brand.name}
|
||||
</Text>
|
||||
</div>
|
||||
</Body>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link, Section, Text } from '@react-email/components'
|
||||
import { baseStyles, colors } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { Section, Text } from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/_styles'
|
||||
import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components'
|
||||
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
@@ -25,55 +25,20 @@ export function CreditPurchaseEmail({
|
||||
|
||||
return (
|
||||
<EmailLayout preview={previewText} showUnsubscribe={false}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||
</Text>
|
||||
<Text style={baseStyles.greeting}>{userName ? `Hi ${userName},` : 'Hi,'}</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Your credit purchase of <strong>{dollarsToCredits(amount).toLocaleString()} credits</strong>{' '}
|
||||
has been confirmed.
|
||||
Your credit purchase of{' '}
|
||||
<EmailStrong>{dollarsToCredits(amount).toLocaleString()} credits</EmailStrong> has been
|
||||
confirmed.
|
||||
</Text>
|
||||
|
||||
<Section style={baseStyles.infoBox}>
|
||||
<Text
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '14px',
|
||||
color: colors.textMuted,
|
||||
fontFamily: baseStyles.fontFamily,
|
||||
}}
|
||||
>
|
||||
Amount Added
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
margin: '4px 0 16px',
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
color: colors.textPrimary,
|
||||
fontFamily: baseStyles.fontFamily,
|
||||
}}
|
||||
>
|
||||
<Text style={baseStyles.infoBoxLabel}>Amount Added</Text>
|
||||
<Text style={{ ...baseStyles.infoBoxValue, marginBottom: '16px' }}>
|
||||
{dollarsToCredits(amount).toLocaleString()} credits
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '14px',
|
||||
color: colors.textMuted,
|
||||
fontFamily: baseStyles.fontFamily,
|
||||
}}
|
||||
>
|
||||
New Balance
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
margin: '4px 0 0',
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
color: colors.textPrimary,
|
||||
fontFamily: baseStyles.fontFamily,
|
||||
}}
|
||||
>
|
||||
<Text style={baseStyles.infoBoxLabel}>New Balance</Text>
|
||||
<Text style={baseStyles.infoBoxValue}>
|
||||
{dollarsToCredits(newBalance).toLocaleString()} credits
|
||||
</Text>
|
||||
</Section>
|
||||
@@ -82,14 +47,11 @@ export function CreditPurchaseEmail({
|
||||
Credits are applied automatically to your workflow executions.
|
||||
</Text>
|
||||
|
||||
<Link href={`${baseUrl}/workspace`} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>View Dashboard</Text>
|
||||
</Link>
|
||||
<EmailButton href={`${baseUrl}/workspace`}>View Dashboard</EmailButton>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
<Text style={baseStyles.footnote}>
|
||||
Purchased on {purchaseDate.toLocaleDateString()}. View balance in Settings → Subscription.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Link, Section, Text } from '@react-email/components'
|
||||
import { baseStyles, colors, typography } from '@/components/emails/_styles'
|
||||
import { proFeatures } from '@/components/emails/billing/constants'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { Text } from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/_styles'
|
||||
import { ProFeaturesBox } from '@/components/emails/billing/pro-features-box'
|
||||
import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components'
|
||||
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
@@ -23,76 +23,20 @@ export function CreditsExhaustedEmail({
|
||||
preview={`You've used all ${dollarsToCredits(limit).toLocaleString()} of your free ${brand.name} credits`}
|
||||
showUnsubscribe={true}
|
||||
>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||
</Text>
|
||||
<Text style={baseStyles.greeting}>{userName ? `Hi ${userName},` : 'Hi,'}</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
You've used all <strong>{dollarsToCredits(limit).toLocaleString()}</strong> of your
|
||||
free credits on {brand.name}. Your workflows are paused until you upgrade.
|
||||
You've used all <EmailStrong>{dollarsToCredits(limit).toLocaleString()}</EmailStrong>{' '}
|
||||
of your free credits on {brand.name}. Your workflows are paused until you upgrade.
|
||||
</Text>
|
||||
|
||||
<Section
|
||||
style={{
|
||||
backgroundColor: colors.surfaceSubtle,
|
||||
border: `1px solid ${colors.brandTertiary}20`,
|
||||
borderRadius: '8px',
|
||||
padding: '16px 20px',
|
||||
margin: '16px 0',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: colors.brandTertiary,
|
||||
fontFamily: typography.fontFamily,
|
||||
margin: '0 0 12px 0',
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.5px',
|
||||
}}
|
||||
>
|
||||
Pro includes
|
||||
</Text>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<tbody>
|
||||
{proFeatures.map((feature, i) => (
|
||||
<tr key={i}>
|
||||
<td
|
||||
style={{
|
||||
padding: '6px 0',
|
||||
fontSize: '15px',
|
||||
fontWeight: 600,
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.fontFamily,
|
||||
width: '45%',
|
||||
}}
|
||||
>
|
||||
{feature.label}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '6px 0',
|
||||
fontSize: '14px',
|
||||
color: colors.textMuted,
|
||||
fontFamily: typography.fontFamily,
|
||||
}}
|
||||
>
|
||||
{feature.desc}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Section>
|
||||
<ProFeaturesBox />
|
||||
|
||||
<Link href={upgradeLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Upgrade to Pro</Text>
|
||||
</Link>
|
||||
<EmailButton href={upgradeLink}>Upgrade to Pro</EmailButton>
|
||||
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
<Text style={baseStyles.footnote}>
|
||||
One-time notification when free credits are exhausted.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link, Text } from '@react-email/components'
|
||||
import { Link, Section, Text } from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
@@ -22,26 +22,25 @@ export function EnterpriseSubscriptionEmail({
|
||||
preview={`Your Enterprise Plan is now active on ${brand.name}`}
|
||||
showUnsubscribe={false}
|
||||
>
|
||||
<Text style={baseStyles.paragraph}>Hello {userName},</Text>
|
||||
<Text style={baseStyles.greeting}>Hello {userName},</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Your <strong>Enterprise Plan</strong> is now active. You have full access to advanced
|
||||
features and increased capacity for your workflows.
|
||||
Your <EmailStrong>Enterprise Plan</EmailStrong> is now active. You have full access to
|
||||
advanced features and increased capacity for your workflows.
|
||||
</Text>
|
||||
|
||||
<Link href={effectiveLoginLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Open {brand.name}</Text>
|
||||
</Link>
|
||||
<Section style={baseStyles.infoBox}>
|
||||
<Text style={baseStyles.infoBoxTitle}>Next steps</Text>
|
||||
<Text style={baseStyles.infoBoxList}>
|
||||
• Invite teammates to your organization
|
||||
<br />• Start building your workflows
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
<strong>Next steps:</strong>
|
||||
<br />• Invite teammates to your organization
|
||||
<br />• Start building your workflows
|
||||
</Text>
|
||||
<EmailButton href={effectiveLoginLink}>Open {brand.name}</EmailButton>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
<Text style={baseStyles.footnote}>
|
||||
Questions? Reply to this email or contact us at{' '}
|
||||
<Link href={`mailto:${brand.supportEmail}`} style={baseStyles.link}>
|
||||
{brand.supportEmail}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Link, Section, Text } from '@react-email/components'
|
||||
import { baseStyles, colors, typography } from '@/components/emails/_styles'
|
||||
import { proFeatures } from '@/components/emails/billing/constants'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { Text } from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/_styles'
|
||||
import { ProFeaturesBox } from '@/components/emails/billing/pro-features-box'
|
||||
import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components'
|
||||
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
@@ -26,81 +26,21 @@ export function FreeTierUpgradeEmail({
|
||||
|
||||
return (
|
||||
<EmailLayout preview={previewText} showUnsubscribe={true}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||
</Text>
|
||||
<Text style={baseStyles.greeting}>{userName ? `Hi ${userName},` : 'Hi,'}</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
You've used <strong>{dollarsToCredits(currentUsage).toLocaleString()}</strong> of your{' '}
|
||||
<strong>{dollarsToCredits(limit).toLocaleString()}</strong> free credits ({percentUsed}%).
|
||||
Upgrade to Pro to keep building without interruption.
|
||||
You've used <EmailStrong>{dollarsToCredits(currentUsage).toLocaleString()}</EmailStrong> of
|
||||
your <EmailStrong>{dollarsToCredits(limit).toLocaleString()}</EmailStrong> free credits (
|
||||
{percentUsed}%). Upgrade to Pro to keep building without interruption.
|
||||
</Text>
|
||||
|
||||
{/* Pro Features */}
|
||||
<Section
|
||||
style={{
|
||||
backgroundColor: colors.surfaceSubtle,
|
||||
border: `1px solid ${colors.brandTertiary}20`,
|
||||
borderRadius: '8px',
|
||||
padding: '16px 20px',
|
||||
margin: '16px 0',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: colors.brandTertiary,
|
||||
fontFamily: typography.fontFamily,
|
||||
margin: '0 0 12px 0',
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.5px',
|
||||
}}
|
||||
>
|
||||
Pro includes
|
||||
</Text>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<tbody>
|
||||
{proFeatures.map((feature, i) => (
|
||||
<tr key={i}>
|
||||
<td
|
||||
style={{
|
||||
padding: '6px 0',
|
||||
fontSize: '15px',
|
||||
fontWeight: 600,
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.fontFamily,
|
||||
width: '45%',
|
||||
}}
|
||||
>
|
||||
{feature.label}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '6px 0',
|
||||
fontSize: '14px',
|
||||
color: colors.textMuted,
|
||||
fontFamily: typography.fontFamily,
|
||||
}}
|
||||
>
|
||||
{feature.desc}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Section>
|
||||
<ProFeaturesBox />
|
||||
|
||||
<Link href={upgradeLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Upgrade to Pro</Text>
|
||||
</Link>
|
||||
<EmailButton href={upgradeLink}>Upgrade to Pro</EmailButton>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
One-time notification at 80% usage.
|
||||
</Text>
|
||||
<Text style={baseStyles.footnote}>One-time notification at 80% usage.</Text>
|
||||
</EmailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link, Section, Text } from '@react-email/components'
|
||||
import { Section, Text } from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { EmailButton, EmailLayout } from '@/components/emails/components'
|
||||
import { UPGRADE_REASON_COPY, type UpgradeReason } from '@/lib/billing/upgrade-reasons'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
@@ -39,9 +39,7 @@ export function LimitThresholdEmail({
|
||||
|
||||
return (
|
||||
<EmailLayout preview={previewText} showUnsubscribe={true}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||
</Text>
|
||||
<Text style={baseStyles.greeting}>{userName ? `Hi ${userName},` : 'Hi,'}</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
{lead} Upgrade your plan for more {copy.noun}.
|
||||
@@ -54,17 +52,11 @@ export function LimitThresholdEmail({
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{/* Divider */}
|
||||
<EmailButton href={upgradeLink}>Upgrade</EmailButton>
|
||||
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Link href={upgradeLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Upgrade</Text>
|
||||
</Link>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
<Text style={baseStyles.footnote}>
|
||||
{kind === 'reached'
|
||||
? 'One-time notification at 100% usage.'
|
||||
: 'One-time notification at 80% usage.'}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link, Section, Text } from '@react-email/components'
|
||||
import { baseStyles, colors } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { baseStyles, colors, fontWeight } from '@/components/emails/_styles'
|
||||
import { EmailButton, EmailLayout } from '@/components/emails/components'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
interface PaymentFailedEmailProps {
|
||||
@@ -26,15 +26,12 @@ export function PaymentFailedEmail({
|
||||
|
||||
return (
|
||||
<EmailLayout preview={previewText} showUnsubscribe={false}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||
</Text>
|
||||
<Text style={baseStyles.greeting}>{userName ? `Hi ${userName},` : 'Hi,'}</Text>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
...baseStyles.paragraph,
|
||||
fontSize: '16px',
|
||||
fontWeight: 600,
|
||||
fontWeight: fontWeight.semibold,
|
||||
color: colors.textPrimary,
|
||||
}}
|
||||
>
|
||||
@@ -46,57 +43,39 @@ export function PaymentFailedEmail({
|
||||
unexpected charges. To restore access immediately, please update your payment method.
|
||||
</Text>
|
||||
|
||||
<Section
|
||||
style={{
|
||||
backgroundColor: colors.errorBg,
|
||||
border: `1px solid ${colors.errorBorder}`,
|
||||
borderRadius: '6px',
|
||||
padding: '16px 18px',
|
||||
margin: '16px 0',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
...baseStyles.paragraph,
|
||||
marginBottom: 8,
|
||||
marginTop: 0,
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
Payment Details
|
||||
</Text>
|
||||
<Text style={{ ...baseStyles.paragraph, margin: '4px 0' }}>
|
||||
<Section style={baseStyles.errorBox}>
|
||||
<Text style={baseStyles.infoBoxTitle}>Payment Details</Text>
|
||||
<Text style={baseStyles.infoBoxList}>
|
||||
Amount due: ${amountDue.toFixed(2)}
|
||||
{lastFourDigits && (
|
||||
<>
|
||||
<br />
|
||||
Payment method: •••• {lastFourDigits}
|
||||
</>
|
||||
)}
|
||||
{failureReason && (
|
||||
<>
|
||||
<br />
|
||||
Reason: {failureReason}
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
{lastFourDigits && (
|
||||
<Text style={{ ...baseStyles.paragraph, margin: '4px 0' }}>
|
||||
Payment method: •••• {lastFourDigits}
|
||||
</Text>
|
||||
)}
|
||||
{failureReason && (
|
||||
<Text style={{ ...baseStyles.paragraph, margin: '4px 0' }}>Reason: {failureReason}</Text>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Link href={billingPortalUrl} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Update Payment Method</Text>
|
||||
</Link>
|
||||
<Section style={baseStyles.infoBox}>
|
||||
<Text style={baseStyles.infoBoxTitle}>What happens next</Text>
|
||||
<Text style={baseStyles.infoBoxList}>
|
||||
• Your workflows and automations are currently paused
|
||||
<br />• Update your payment method to restore service immediately
|
||||
<br />• Stripe will automatically retry the charge once payment is updated
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<EmailButton href={billingPortalUrl}>Update Payment Method</EmailButton>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.paragraph, fontWeight: 'bold' }}>What happens next?</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
• Your workflows and automations are currently paused
|
||||
<br />• Update your payment method to restore service immediately
|
||||
<br />• Stripe will automatically retry the charge once payment is updated
|
||||
</Text>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
<Text style={baseStyles.footnote}>
|
||||
Common issues: expired card, insufficient funds, or incorrect billing info. Need help?{' '}
|
||||
<Link href={`mailto:${brand.supportEmail}`} style={baseStyles.link}>
|
||||
{brand.supportEmail}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link, Text } from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
@@ -19,17 +19,13 @@ export function PlanWelcomeEmail({ planName, userName, loginLink }: PlanWelcomeE
|
||||
|
||||
return (
|
||||
<EmailLayout preview={previewText} showUnsubscribe={true}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||
</Text>
|
||||
<Text style={baseStyles.greeting}>{userName ? `Hi ${userName},` : 'Hi,'}</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Welcome to <strong>{planName}</strong>! You're all set to build, test, and scale your
|
||||
workflows.
|
||||
Welcome to <EmailStrong>{planName}</EmailStrong>! You're all set to build, test, and scale
|
||||
your workflows.
|
||||
</Text>
|
||||
|
||||
<Link href={cta} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Open {brand.name}</Text>
|
||||
</Link>
|
||||
<EmailButton href={cta}>Open {brand.name}</EmailButton>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Want help getting started?{' '}
|
||||
@@ -39,12 +35,9 @@ export function PlanWelcomeEmail({ planName, userName, loginLink }: PlanWelcomeE
|
||||
with our team.
|
||||
</Text>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
Manage your subscription in Settings → Subscription.
|
||||
</Text>
|
||||
<Text style={baseStyles.footnote}>Manage your subscription in Settings → Subscription.</Text>
|
||||
</EmailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Section, Text } from '@react-email/components'
|
||||
import { baseStyles, colors, fontWeight } from '@/components/emails/_styles'
|
||||
import { proFeatures } from '@/components/emails/billing/constants'
|
||||
|
||||
const CELL = { ...baseStyles.infoBoxList, padding: '4px 0' }
|
||||
const LABEL_CELL = {
|
||||
...CELL,
|
||||
fontWeight: fontWeight.semibold,
|
||||
color: colors.textPrimary,
|
||||
width: '45%',
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Pro includes" panel shared by the two free-tier upgrade prompts.
|
||||
*
|
||||
* The two-column table is what keeps a feature and its qualifier on one row
|
||||
* across email clients, which a list cannot do reliably.
|
||||
*/
|
||||
export function ProFeaturesBox() {
|
||||
return (
|
||||
<Section style={baseStyles.infoBox}>
|
||||
<Text style={baseStyles.infoBoxTitle}>Pro includes</Text>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<tbody>
|
||||
{proFeatures.map((feature) => (
|
||||
<tr key={feature.label}>
|
||||
<td style={LABEL_CELL}>{feature.label}</td>
|
||||
<td style={CELL}>{feature.desc}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProFeaturesBox
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link, Section, Text } from '@react-email/components'
|
||||
import { Section, Text } from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { EmailButton, EmailLayout } from '@/components/emails/components'
|
||||
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
@@ -33,9 +33,7 @@ export function UsageLimitReachedEmail({
|
||||
|
||||
return (
|
||||
<EmailLayout preview={previewText} showUnsubscribe={true}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||
</Text>
|
||||
<Text style={baseStyles.greeting}>{userName ? `Hi ${userName},` : 'Hi,'}</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
{isOrganization
|
||||
@@ -53,25 +51,19 @@ export function UsageLimitReachedEmail({
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
{isOrganization
|
||||
? 'Raise the organization usage limit in billing settings to resume.'
|
||||
: 'Raise your usage limit in billing settings, or upgrade your plan, to resume.'}
|
||||
</Text>
|
||||
|
||||
<Link href={ctaLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>
|
||||
{isOrganization ? 'Raise Organization Limit' : 'Raise Usage Limit'}
|
||||
</Text>
|
||||
</Link>
|
||||
<EmailButton href={ctaLink}>
|
||||
{isOrganization ? 'Raise Organization Limit' : 'Raise Usage Limit'}
|
||||
</EmailButton>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
<Text style={baseStyles.footnote}>
|
||||
Sent to the people who manage billing for this account.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link, Section, Text } from '@react-email/components'
|
||||
import { Section, Text } from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { EmailButton, EmailLayout } from '@/components/emails/components'
|
||||
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
@@ -27,9 +27,7 @@ export function UsageThresholdEmail({
|
||||
|
||||
return (
|
||||
<EmailLayout preview={previewText} showUnsubscribe={true}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||
</Text>
|
||||
<Text style={baseStyles.greeting}>{userName ? `Hi ${userName},` : 'Hi,'}</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
You're approaching your monthly budget on the {planName} plan.
|
||||
@@ -43,23 +41,15 @@ export function UsageThresholdEmail({
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
To avoid interruptions, consider increasing your monthly limit.
|
||||
</Text>
|
||||
|
||||
<Link href={ctaLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Review Limits</Text>
|
||||
</Link>
|
||||
<EmailButton href={ctaLink}>Review Limits</EmailButton>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
One-time notification at 80% usage.
|
||||
</Text>
|
||||
<Text style={baseStyles.footnote}>One-time notification at 80% usage.</Text>
|
||||
</EmailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Link, Text } from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/_styles'
|
||||
|
||||
/** `Link` renders an underline by default; the pill owns its own chrome. */
|
||||
const RESET_LINK_STYLE = { textDecoration: 'none' } as const
|
||||
|
||||
interface EmailButtonProps {
|
||||
href: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* The primary CTA pill — a `Link` wrapping a `Text` so the tappable area covers
|
||||
* the whole pill in clients that ignore padding on an anchor.
|
||||
*
|
||||
* Every template repeated this three-node shape verbatim, including the
|
||||
* underline reset; the styling lives in {@link baseStyles.button}, which
|
||||
* transcribes the platform's primary Chip.
|
||||
*/
|
||||
export function EmailButton({ href, children }: EmailButtonProps) {
|
||||
return (
|
||||
<Link href={href} style={RESET_LINK_STYLE}>
|
||||
<Text style={baseStyles.button}>{children}</Text>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmailButton
|
||||
@@ -1,9 +1,35 @@
|
||||
import { Container, Img, Link, Section } from '@react-email/components'
|
||||
import { baseStyles, colors, spacing, typography } from '@/components/emails/_styles'
|
||||
import { baseStyles, colors, spacing } from '@/components/emails/_styles'
|
||||
import { isHosted } from '@/lib/core/config/env-flags'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
/**
|
||||
* Social mark display size. Every `static/*-icon.png` is 40×40, so 20px is a
|
||||
* clean 2x source — email clients do no responsive image selection, so the
|
||||
* asset must be authored at 2x and pinned here. This is deliberately NOT the
|
||||
* platform's 14px UI-icon size: these are brand marks in an image, not
|
||||
* `--text-icon` glyphs, and 14px renders them illegibly.
|
||||
*/
|
||||
const SOCIAL_ICON_SIZE = 20
|
||||
|
||||
/**
|
||||
* `display: block` removes the 2–3px gap Outlook adds under inline images;
|
||||
* `border: 0` prevents the blue link border older Outlook draws around a linked
|
||||
* image.
|
||||
*/
|
||||
const socialIconStyle = { display: 'block' as const, border: 0 }
|
||||
|
||||
/** Trailing gap only, so the row starts flush with the gutter. */
|
||||
const SOCIAL_CELL_STYLE = { paddingRight: 16 } as const
|
||||
|
||||
const SOCIAL_LINKS = [
|
||||
{ path: 'x', label: 'X', icon: 'x-icon.png' },
|
||||
{ path: 'linkedin', label: 'LinkedIn', icon: 'linkedin-icon.png' },
|
||||
{ path: 'github', label: 'GitHub', icon: 'github-icon.png' },
|
||||
{ path: 'slack', label: 'Slack', icon: 'slack-icon.png' },
|
||||
] as const
|
||||
|
||||
interface EmailFooterProps {
|
||||
baseUrl?: string
|
||||
messageId?: string
|
||||
@@ -30,23 +56,6 @@ export function EmailFooter({
|
||||
const brand = getBrandConfig()
|
||||
const isWhitelabeled = brand.isWhitelabeled
|
||||
|
||||
const footerLinkStyle = {
|
||||
color: colors.textMuted,
|
||||
textDecoration: 'underline',
|
||||
fontWeight: 'normal' as const,
|
||||
fontFamily: typography.fontFamily,
|
||||
}
|
||||
|
||||
/**
|
||||
* Social icons are linked images. `display: block` removes the 2–3px gap
|
||||
* Outlook adds under inline images, and `border: 0` prevents the blue link
|
||||
* border older Outlook versions draw around linked images.
|
||||
*/
|
||||
const socialIconStyle = {
|
||||
display: 'block' as const,
|
||||
border: 0,
|
||||
}
|
||||
|
||||
return (
|
||||
<Section
|
||||
style={{
|
||||
@@ -80,50 +89,19 @@ export function EmailFooter({
|
||||
<table cellPadding={0} cellSpacing={0} style={{ border: 0 }}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align='left' style={{ padding: '0 8px 0 0' }}>
|
||||
<Link href={`${baseUrl}/x`} rel='noopener noreferrer'>
|
||||
<Img
|
||||
src={`${baseUrl}/static/x-icon.png`}
|
||||
width='20'
|
||||
height='20'
|
||||
alt='X'
|
||||
style={socialIconStyle}
|
||||
/>
|
||||
</Link>
|
||||
</td>
|
||||
<td align='left' style={{ padding: '0 8px' }}>
|
||||
<Link href={`${baseUrl}/linkedin`} rel='noopener noreferrer'>
|
||||
<Img
|
||||
src={`${baseUrl}/static/linkedin-icon.png`}
|
||||
width='20'
|
||||
height='20'
|
||||
alt='LinkedIn'
|
||||
style={socialIconStyle}
|
||||
/>
|
||||
</Link>
|
||||
</td>
|
||||
<td align='left' style={{ padding: '0 8px' }}>
|
||||
<Link href={`${baseUrl}/github`} rel='noopener noreferrer'>
|
||||
<Img
|
||||
src={`${baseUrl}/static/github-icon.png`}
|
||||
width='20'
|
||||
height='20'
|
||||
alt='GitHub'
|
||||
style={socialIconStyle}
|
||||
/>
|
||||
</Link>
|
||||
</td>
|
||||
<td align='left' style={{ padding: '0 8px' }}>
|
||||
<Link href={`${baseUrl}/slack`} rel='noopener noreferrer'>
|
||||
<Img
|
||||
src={`${baseUrl}/static/slack-icon.png`}
|
||||
width='20'
|
||||
height='20'
|
||||
alt='Slack'
|
||||
style={socialIconStyle}
|
||||
/>
|
||||
</Link>
|
||||
</td>
|
||||
{SOCIAL_LINKS.map(({ path, label, icon }) => (
|
||||
<td key={path} align='left' style={SOCIAL_CELL_STYLE}>
|
||||
<Link href={`${baseUrl}/${path}`} rel='noopener noreferrer'>
|
||||
<Img
|
||||
src={`${baseUrl}/static/${icon}`}
|
||||
width={SOCIAL_ICON_SIZE}
|
||||
height={SOCIAL_ICON_SIZE}
|
||||
alt={label}
|
||||
style={socialIconStyle}
|
||||
/>
|
||||
</Link>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -165,14 +143,18 @@ export function EmailFooter({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Contact row */}
|
||||
<tr>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
</td>
|
||||
<td style={baseStyles.footerText}>
|
||||
Questions?{' '}
|
||||
<a href={`mailto:${brand.supportEmail}`} style={footerLinkStyle}>
|
||||
{/*
|
||||
A raw anchor, not `<Link>`: react-email's Link hardcodes
|
||||
target="_blank", which on a mailto: opens a blank tab beside
|
||||
the compose window in most webmail clients.
|
||||
*/}
|
||||
<a href={`mailto:${brand.supportEmail}`} style={baseStyles.footerLink}>
|
||||
{brand.supportEmail}
|
||||
</a>
|
||||
</td>
|
||||
@@ -187,7 +169,6 @@ export function EmailFooter({
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Message ID row (optional) */}
|
||||
{messageId && (
|
||||
<>
|
||||
<tr>
|
||||
@@ -209,30 +190,37 @@ export function EmailFooter({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Links row */}
|
||||
<tr>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
</td>
|
||||
<td style={baseStyles.footerText}>
|
||||
<a href={`${baseUrl}/privacy`} style={footerLinkStyle} rel='noopener noreferrer'>
|
||||
<Link
|
||||
href={`${baseUrl}/privacy`}
|
||||
style={baseStyles.footerLink}
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
Privacy Policy
|
||||
</a>{' '}
|
||||
</Link>{' '}
|
||||
•{' '}
|
||||
<a href={`${baseUrl}/terms`} style={footerLinkStyle} rel='noopener noreferrer'>
|
||||
<Link
|
||||
href={`${baseUrl}/terms`}
|
||||
style={baseStyles.footerLink}
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
Terms of Service
|
||||
</a>
|
||||
</Link>
|
||||
{showUnsubscribe && (
|
||||
<>
|
||||
{' '}
|
||||
•{' '}
|
||||
<a
|
||||
<Link
|
||||
href={`${baseUrl}/unsubscribe?token={{UNSUBSCRIBE_TOKEN}}&email={{UNSUBSCRIBE_EMAIL}}`}
|
||||
style={footerLinkStyle}
|
||||
style={baseStyles.footerLink}
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
Unsubscribe
|
||||
</a>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
@@ -241,7 +229,6 @@ export function EmailFooter({
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Copyright row */}
|
||||
<tr>
|
||||
<td style={baseStyles.spacer} height={16}>
|
||||
|
||||
|
||||
@@ -4,6 +4,16 @@ import { EmailFooter } from '@/components/emails/components/email-footer'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
/**
|
||||
* Wordmark display size — exactly 1/4 of `wordmark.png`'s intrinsic 272×164.
|
||||
* The asset is a 4x source and must stay that way, since email clients do no
|
||||
* responsive image selection; changing one dimension alone distorts the mark.
|
||||
*/
|
||||
const WORDMARK_SIZE = { height: '41', width: '68' } as const
|
||||
|
||||
/** Whitelabeled logos are arbitrary aspect ratios, so only height is pinned. */
|
||||
const CUSTOM_LOGO_SIZE = { height: '34' } as const
|
||||
|
||||
interface EmailLayoutProps {
|
||||
/** Preview text shown in email client list view */
|
||||
preview: string
|
||||
@@ -35,9 +45,16 @@ export function EmailLayout({
|
||||
return (
|
||||
<Html>
|
||||
<Head>
|
||||
{/*
|
||||
`fallbackFontFamily` only accepts react-email's fixed union, so it
|
||||
cannot express the platform's full chain (`system-ui`, `Segoe UI`,
|
||||
`Roboto`, …) — this is the closest allowed subset. The complete chain
|
||||
lives on `typography.fontFamily`, which is applied inline to every
|
||||
element and is what clients that strip `@font-face` actually use.
|
||||
*/}
|
||||
<Font
|
||||
fontFamily='Season Sans'
|
||||
fallbackFontFamily={['Helvetica', 'sans-serif']}
|
||||
fallbackFontFamily={['Helvetica', 'Arial', 'sans-serif']}
|
||||
webFont={{
|
||||
url: `${baseUrl}/brand/fonts/SeasonSansUprightsVF.woff2`,
|
||||
format: 'woff2',
|
||||
@@ -48,23 +65,19 @@ export function EmailLayout({
|
||||
</Head>
|
||||
<Preview>{preview}</Preview>
|
||||
<Body style={baseStyles.main}>
|
||||
{/* Main card container */}
|
||||
<Container style={baseStyles.container}>
|
||||
{/* Header with logo */}
|
||||
<Section style={baseStyles.header}>
|
||||
<Img
|
||||
src={brand.logoUrl || `${baseUrl}/brand/color/email/wordmark.png`}
|
||||
alt={brand.name}
|
||||
{...(hasCustomLogo ? { height: '34' } : { height: '41', width: '68' })}
|
||||
{...(hasCustomLogo ? CUSTOM_LOGO_SIZE : WORDMARK_SIZE)}
|
||||
style={hasCustomLogo ? { display: 'block', width: 'auto' } : { display: 'block' }}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Content */}
|
||||
<Section style={baseStyles.content}>{children}</Section>
|
||||
</Container>
|
||||
|
||||
{/* Footer in gray section */}
|
||||
{!hideFooter && <EmailFooter baseUrl={baseUrl} showUnsubscribe={showUnsubscribe} />}
|
||||
</Body>
|
||||
</Html>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { fontWeight } from '@/components/emails/_styles'
|
||||
|
||||
const STRONG_STYLE = { fontWeight: fontWeight.semibold } as const
|
||||
|
||||
interface EmailStrongProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline emphasis inside body copy.
|
||||
*
|
||||
* A bare `strong` element inherits the client's UA weight of 700, which is off
|
||||
* the platform's three-step scale (400/500/600) and reads noticeably heavier
|
||||
* than anything in the app. This pins it to semibold while keeping the element
|
||||
* itself, so the emphasis stays semantic for screen readers.
|
||||
*/
|
||||
export function EmailStrong({ children }: EmailStrongProps) {
|
||||
return <strong style={STRONG_STYLE}>{children}</strong>
|
||||
}
|
||||
|
||||
export default EmailStrong
|
||||
@@ -1,2 +1,4 @@
|
||||
export { EmailButton } from './email-button'
|
||||
export { EmailFooter } from './email-footer'
|
||||
export { EmailLayout } from './email-layout'
|
||||
export { EmailStrong } from './email-strong'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Link, Text } from '@react-email/components'
|
||||
import { Fragment } from 'react'
|
||||
import { Section, Text } from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
interface WorkspaceInvitation {
|
||||
@@ -58,47 +59,44 @@ export function BatchInvitationEmail({
|
||||
preview={`You've been invited to join ${organizationName}${hasWorkspaces ? ` and ${workspaceInvitations.length} workspace(s)` : ''}`}
|
||||
showUnsubscribe={false}
|
||||
>
|
||||
<Text style={baseStyles.paragraph}>Hello,</Text>
|
||||
<Text style={baseStyles.greeting}>Hello,</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
<strong>{inviterName}</strong> has invited you to join <strong>{organizationName}</strong>{' '}
|
||||
on {brand.name}.
|
||||
<EmailStrong>{inviterName}</EmailStrong> has invited you to join{' '}
|
||||
<EmailStrong>{organizationName}</EmailStrong> on {brand.name}.
|
||||
</Text>
|
||||
|
||||
{/* Team Role Information */}
|
||||
<Text style={baseStyles.paragraph}>
|
||||
<strong>Team Role:</strong> {getRoleLabel(organizationRole)}
|
||||
</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
{organizationRole === 'admin'
|
||||
? "As a Team Admin, you'll be able to manage team members, billing, and workspace access."
|
||||
: "As a Team Member, you'll have access to shared team billing and can be invited to workspaces."}
|
||||
</Text>
|
||||
<Section style={baseStyles.infoBox}>
|
||||
<Text style={baseStyles.infoBoxTitle}>Team role</Text>
|
||||
<Text style={baseStyles.infoBoxList}>
|
||||
{getRoleLabel(organizationRole)} —{' '}
|
||||
{organizationRole === 'admin'
|
||||
? 'you can manage team members, billing, and workspace access.'
|
||||
: 'you have access to shared team billing and can be invited to workspaces.'}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{/* Workspace Invitations */}
|
||||
{hasWorkspaces && (
|
||||
<>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
<strong>
|
||||
Workspace Access ({workspaceInvitations.length} workspace
|
||||
{workspaceInvitations.length !== 1 ? 's' : ''}):
|
||||
</strong>
|
||||
<Section style={baseStyles.infoBox}>
|
||||
<Text style={baseStyles.infoBoxTitle}>
|
||||
Workspace access ({workspaceInvitations.length} workspace
|
||||
{workspaceInvitations.length !== 1 ? 's' : ''})
|
||||
</Text>
|
||||
{workspaceInvitations.map((ws) => (
|
||||
<Text key={ws.workspaceId} style={{ ...baseStyles.paragraph, marginLeft: '20px' }}>
|
||||
• <strong>{ws.workspaceName}</strong> - {getPermissionLabel(ws.permission)}
|
||||
</Text>
|
||||
))}
|
||||
</>
|
||||
<Text style={baseStyles.infoBoxList}>
|
||||
{workspaceInvitations.map((ws, index) => (
|
||||
<Fragment key={ws.workspaceId}>
|
||||
{index > 0 && <br />}
|
||||
{ws.workspaceName} — {getPermissionLabel(ws.permission)}
|
||||
</Fragment>
|
||||
))}
|
||||
</Text>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Link href={acceptUrl} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Accept Invitation</Text>
|
||||
</Link>
|
||||
<EmailButton href={acceptUrl}>Accept Invitation</EmailButton>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
<Text style={baseStyles.footnote}>
|
||||
Invitation expires in 7 days. If unexpected, you can ignore this email.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Link, Text } from '@react-email/components'
|
||||
import { Text } from '@react-email/components'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { baseStyles } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
@@ -40,20 +40,17 @@ export function InvitationEmail({
|
||||
preview={`You've been invited to join ${organizationName} on ${brand.name}`}
|
||||
showUnsubscribe={false}
|
||||
>
|
||||
<Text style={baseStyles.paragraph}>Hello,</Text>
|
||||
<Text style={baseStyles.greeting}>Hello,</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
<strong>{inviterName}</strong> invited you to join <strong>{organizationName}</strong> on{' '}
|
||||
{brand.name}.
|
||||
<EmailStrong>{inviterName}</EmailStrong> invited you to join{' '}
|
||||
<EmailStrong>{organizationName}</EmailStrong> on {brand.name}.
|
||||
</Text>
|
||||
|
||||
<Link href={enhancedLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Accept Invitation</Text>
|
||||
</Link>
|
||||
<EmailButton href={enhancedLink}>Accept Invitation</EmailButton>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
<Text style={baseStyles.footnote}>
|
||||
Invitation expires in 48 hours. If unexpected, you can ignore this email.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link, Text } from '@react-email/components'
|
||||
import { Text } from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
interface WorkspaceAddedEmailProps {
|
||||
@@ -22,21 +22,17 @@ export function WorkspaceAddedEmail({
|
||||
|
||||
return (
|
||||
<EmailLayout preview={preview} showUnsubscribe={false}>
|
||||
<Text style={baseStyles.paragraph}>Hello,</Text>
|
||||
<Text style={baseStyles.greeting}>Hello,</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
<strong>{inviterName}</strong> added you to the <strong>{workspaceName}</strong> workspace
|
||||
on {brand.name}.
|
||||
<EmailStrong>{inviterName}</EmailStrong> added you to the{' '}
|
||||
<EmailStrong>{workspaceName}</EmailStrong> workspace on {brand.name}.
|
||||
</Text>
|
||||
|
||||
<Link href={workspaceLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Open workspace</Text>
|
||||
</Link>
|
||||
<EmailButton href={workspaceLink}>Open workspace</EmailButton>
|
||||
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
If this was unexpected, contact a workspace admin.
|
||||
</Text>
|
||||
<Text style={baseStyles.footnote}>If this was unexpected, contact a workspace admin.</Text>
|
||||
</EmailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link, Text } from '@react-email/components'
|
||||
import { Text } from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
interface WorkspaceInvitationEmailProps {
|
||||
@@ -23,9 +23,9 @@ export function WorkspaceInvitationEmail({
|
||||
|
||||
return (
|
||||
<EmailLayout preview={preview} showUnsubscribe={false}>
|
||||
<Text style={baseStyles.paragraph}>Hello,</Text>
|
||||
<Text style={baseStyles.greeting}>Hello,</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
<strong>{inviterName}</strong> invited you to join the{' '}
|
||||
<EmailStrong>{inviterName}</EmailStrong> invited you to join the{' '}
|
||||
{workspaceNames.map((name, index) => (
|
||||
<span key={`${name}-${index}`}>
|
||||
{index > 0 &&
|
||||
@@ -34,19 +34,17 @@ export function WorkspaceInvitationEmail({
|
||||
? ', and '
|
||||
: ' and '
|
||||
: ', ')}
|
||||
<strong>{name}</strong>
|
||||
<EmailStrong>{name}</EmailStrong>
|
||||
</span>
|
||||
))}{' '}
|
||||
{isMultiple ? 'workspaces' : 'workspace'} on {brand.name}.
|
||||
</Text>
|
||||
|
||||
<Link href={invitationLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Accept Invitation</Text>
|
||||
</Link>
|
||||
<EmailButton href={invitationLink}>Accept Invitation</EmailButton>
|
||||
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
<Text style={baseStyles.footnote}>
|
||||
Invitation expires in 7 days. If unexpected, you can ignore this email.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link, Section, Text } from '@react-email/components'
|
||||
import { Section, Text } from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { EmailButton, EmailLayout } from '@/components/emails/components'
|
||||
import {
|
||||
SCHEDULE_DISABLE_REASON_COPY,
|
||||
type ScheduleDisableReason,
|
||||
@@ -39,9 +39,7 @@ export function ScheduleDisabledEmail({
|
||||
|
||||
return (
|
||||
<EmailLayout preview={previewText} showUnsubscribe={true}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{recipientName ? `Hi ${recipientName},` : 'Hi,'}
|
||||
</Text>
|
||||
<Text style={baseStyles.greeting}>{recipientName ? `Hi ${recipientName},` : 'Hi,'}</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
{brand.name} turned off the schedule for {resourceLabel}. It will not run again until you
|
||||
@@ -53,16 +51,11 @@ export function ScheduleDisabledEmail({
|
||||
<Text style={baseStyles.infoBoxList}>{reasonCopy}</Text>
|
||||
</Section>
|
||||
|
||||
{manageLink ? (
|
||||
<Link href={manageLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Open workflow</Text>
|
||||
</Link>
|
||||
) : null}
|
||||
{manageLink ? <EmailButton href={manageLink}>Open workflow</EmailButton> : null}
|
||||
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
<Text style={baseStyles.footnote}>
|
||||
Sent to workspace admins and the person who created this schedule.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Text } from '@react-email/components'
|
||||
import { format } from 'date-fns'
|
||||
import { baseStyles } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { EmailLayout, EmailStrong } from '@/components/emails/components'
|
||||
|
||||
interface HelpConfirmationEmailProps {
|
||||
type?: 'bug' | 'feedback' | 'feature_request' | 'other'
|
||||
@@ -36,10 +36,10 @@ export function HelpConfirmationEmail({
|
||||
preview={`Your ${typeLabel.toLowerCase()} has been received`}
|
||||
showUnsubscribe={false}
|
||||
>
|
||||
<Text style={baseStyles.paragraph}>Hello,</Text>
|
||||
<Text style={baseStyles.greeting}>Hello,</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
We've received your <strong>{typeLabel.toLowerCase()}</strong> and will get back to you
|
||||
shortly.
|
||||
We've received your <EmailStrong>{typeLabel.toLowerCase()}</EmailStrong> and will get back
|
||||
to you shortly.
|
||||
</Text>
|
||||
|
||||
{attachmentCount > 0 && (
|
||||
@@ -48,10 +48,9 @@ export function HelpConfirmationEmail({
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
<Text style={baseStyles.footnote}>
|
||||
Submitted on {format(submittedDate, 'MMMM do, yyyy')}.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
|
||||
Reference in New Issue
Block a user