chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
'use client'
import { useEffect } from 'react'
import { getBrandConfig } from '@/ee/whitelabeling'
interface BrandedLayoutProps {
children: React.ReactNode
}
export function BrandedLayout({ children }: BrandedLayoutProps) {
useEffect(() => {
const config = getBrandConfig()
// Update document title
if (config.name !== 'Sim') {
document.title = config.name
}
// Update favicon
if (config.faviconUrl) {
const faviconLink = document.querySelector("link[rel*='icon']") as HTMLLinkElement
if (faviconLink) {
faviconLink.href = config.faviconUrl
}
}
// Load custom CSS if provided
if (config.customCssUrl) {
const customCssId = 'custom-brand-css'
let customCssLink = document.getElementById(customCssId) as HTMLLinkElement
if (!customCssLink) {
customCssLink = document.createElement('link')
customCssLink.id = customCssId
customCssLink.rel = 'stylesheet'
customCssLink.href = config.customCssUrl
document.head.appendChild(customCssLink)
}
}
}, [])
return <>{children}</>
}
+299
View File
@@ -0,0 +1,299 @@
/**
* Base styles for all email templates.
* Colors are derived from globals.css light mode tokens.
*/
import { getBrandConfig } from '@/ee/whitelabeling'
/** Color tokens from globals.css (light mode), brand-aware for whitelabeled instances */
function buildColors() {
const brand = getBrandConfig()
const isWhitelabeled = brand.isWhitelabeled
const accentColor =
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',
/** Card/container background — platform `--surface-2` */
bgCard: '#ffffff',
/** Primary text — 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',
/** 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 */
surfaceSubtle: '#f7f7f7',
/** Error surface fill — platform `--terminal-status-error-bg` */
errorBg: '#fef2f2',
/** Error surface border — platform `--error-muted` */
errorBorder: '#fecaca',
/** Footer background — matches the canvas */
footerBg: '#f8f8f8',
}
}
export const colors = buildColors()
/** Typography settings */
export const typography = {
fontFamily:
"'Season Sans', -apple-system, 'SF Pro Display', 'SF Pro Text', 'Helvetica', sans-serif",
fontSize: {
body: '16px',
small: '14px',
caption: '12px',
},
lineHeight: {
body: '24px',
caption: '20px',
},
}
/** Spacing values */
export const spacing = {
containerWidth: 600,
gutter: 40,
sectionGap: 20,
paragraphGap: 12,
/** Logo width in pixels */
logoWidth: 90,
}
export const baseStyles = {
fontFamily: typography.fontFamily,
/** Main body wrapper with outer background */
main: {
backgroundColor: colors.bgOuter,
fontFamily: typography.fontFamily,
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',
overflow: 'hidden',
},
/** Header section with logo */
header: {
padding: `32px ${spacing.gutter}px 16px ${spacing.gutter}px`,
textAlign: 'left' as const,
},
/** Main content area with horizontal padding */
content: {
padding: `0 ${spacing.gutter}px 32px ${spacing.gutter}px`,
},
/** Standard paragraph text */
paragraph: {
fontSize: typography.fontSize.body,
lineHeight: typography.lineHeight.body,
color: colors.textSecondary,
fontWeight: 400,
fontFamily: typography.fontFamily,
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',
},
/** Primary CTA button - matches the platform's primary Chip (inverse fill, rounded-lg, h-30, text-sm) */
button: {
display: 'inline-block',
backgroundColor: colors.brandTertiary,
color: '#ffffff',
fontWeight: 400,
fontSize: '14px',
lineHeight: '30px',
padding: '0 12px',
borderRadius: '8px',
textDecoration: 'none',
textAlign: 'center' as const,
margin: '4px 0',
fontFamily: typography.fontFamily,
},
/** Link text style - neutral color, so it carries an underline to read as a link */
link: {
color: colors.brandTertiary,
fontWeight: 400,
textDecoration: 'underline',
},
/** Horizontal divider */
divider: {
borderTop: `1px solid ${colors.divider}`,
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 text style */
footerText: {
fontSize: typography.fontSize.caption,
lineHeight: typography.lineHeight.caption,
color: colors.textMuted,
fontFamily: typography.fontFamily,
margin: '0 0 10px 0',
},
/** Code/OTP container */
codeContainer: {
margin: '12px 0',
padding: '12px 16px',
backgroundColor: colors.surfaceSubtle,
borderRadius: '8px',
border: `1px solid ${colors.divider}`,
textAlign: 'center' as const,
},
/** Code/OTP text */
code: {
fontSize: '24px',
fontWeight: 'bold' as const,
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: {
backgroundColor: colors.surfaceSubtle,
padding: '16px 18px',
borderRadius: '8px',
margin: '16px 0',
},
/** Info box title */
infoBoxTitle: {
fontSize: typography.fontSize.body,
lineHeight: typography.lineHeight.body,
fontWeight: 600,
color: colors.textPrimary,
fontFamily: typography.fontFamily,
margin: '0 0 8px 0',
},
/** Info box list content */
infoBoxList: {
fontSize: typography.fontSize.body,
lineHeight: '1.6',
color: colors.textSecondary,
fontFamily: typography.fontFamily,
margin: 0,
},
/** Section borders - decorative accent line */
sectionsBorders: {
width: '100%',
display: 'flex',
},
sectionBorder: {
borderBottom: `1px solid ${colors.divider}`,
width: '249px',
},
sectionCenter: {
borderBottom: `1px solid ${colors.brandTertiary}`,
width: '102px',
},
/** Spacer row for vertical spacing in tables */
spacer: {
border: 0,
margin: 0,
padding: 0,
fontSize: '1px',
lineHeight: '1px',
},
/** Gutter cell for horizontal padding in tables */
gutter: {
border: 0,
margin: 0,
padding: 0,
fontSize: '1px',
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) */
export const plainEmailStyles = {
body: {
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
backgroundColor: '#ffffff',
margin: '0',
padding: '0',
},
container: {
maxWidth: '560px',
margin: '40px auto',
padding: '0 24px',
},
p: {
fontSize: '15px',
lineHeight: '1.6',
color: '#1a1a1a',
margin: '0 0 16px',
},
} as const
@@ -0,0 +1 @@
export { baseStyles, colors, plainEmailStyles, spacing, typography } from './base'
@@ -0,0 +1,46 @@
import { Link, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getBrandConfig } from '@/ee/whitelabeling'
interface ExistingAccountEmailProps {
username?: string
}
/**
* Sent out-of-band when someone attempts to sign up with an email that already
* has an account. The sign-up endpoint itself returns a generic success
* response to avoid account enumeration, so this email is how the real account
* owner learns of the attempt.
*/
export function ExistingAccountEmail({ username = '' }: ExistingAccountEmailProps) {
const brand = getBrandConfig()
const loginLink = `${getBaseUrl()}/login`
return (
<EmailLayout
preview={`Someone tried to sign up with your ${brand.name} email`}
showUnsubscribe={false}
>
<Text style={baseStyles.paragraph}>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>
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
If this wasn't you, no action is needed no account was created or changed.
</Text>
</EmailLayout>
)
}
export default ExistingAccountEmail
+5
View File
@@ -0,0 +1,5 @@
export { ExistingAccountEmail } from './existing-account-email'
export { OnboardingFollowupEmail } from './onboarding-followup-email'
export { OTPVerificationEmail } from './otp-verification-email'
export { ResetPasswordEmail } from './reset-password-email'
export { WelcomeEmail } from './welcome-email'
@@ -0,0 +1,38 @@
import { Body, Head, Html, Preview, Text } from '@react-email/components'
import { plainEmailStyles as styles } from '@/components/emails/_styles'
interface OnboardingFollowupEmailProps {
userName?: string
}
export function OnboardingFollowupEmail({ userName }: OnboardingFollowupEmailProps) {
return (
<Html>
<Head />
<Preview>Quick question</Preview>
<Body style={styles.body}>
<div style={styles.container}>
<Text style={styles.p}>{userName ? `Hey ${userName},` : 'Hey,'}</Text>
<Text style={styles.p}>
It&apos;s been a few days since you signed up. I hope you&apos;re enjoying Sim!
</Text>
<Text style={styles.p}>
I&apos;d love to know what did you expect when you signed up vs. what did you get?
</Text>
<Text style={styles.p}>
A reply with your thoughts would really help us improve the product for everyone.
</Text>
<Text style={styles.p}>
Thanks,
<br />
Emir
<br />
Founder, Sim
</Text>
</div>
</Body>
</Html>
)
}
export default OnboardingFollowupEmail
@@ -0,0 +1,59 @@
import { Section, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/ee/whitelabeling'
interface OTPVerificationEmailProps {
otp: string
email?: string
type?: 'sign-in' | 'email-verification' | 'change-email' | 'forget-password' | 'chat-access'
chatTitle?: string
}
const getSubjectByType = (type: string, brandName: string, chatTitle?: string) => {
switch (type) {
case 'sign-in':
return `Sign in to ${brandName}`
case 'email-verification':
return `Verify your email for ${brandName}`
case 'change-email':
return `Verify your new email for ${brandName}`
case 'forget-password':
return `Reset your ${brandName} password`
case 'chat-access':
return `Verification code for ${chatTitle || 'Chat'}`
default:
return `Verification code for ${brandName}`
}
}
export function OTPVerificationEmail({
otp,
email = '',
type = 'email-verification',
chatTitle,
}: OTPVerificationEmailProps) {
const brand = getBrandConfig()
return (
<EmailLayout preview={getSubjectByType(type, brand.name, chatTitle)} showUnsubscribe={false}>
<Text style={baseStyles.paragraph}>Your verification code:</Text>
<Section style={baseStyles.codeContainer}>
<Text style={baseStyles.code}>{otp}</Text>
</Section>
<Text style={baseStyles.paragraph}>This code will expire in 15 minutes.</Text>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Do not share this code with anyone. If you didn't request this code, you can safely ignore
this email.
</Text>
</EmailLayout>
)
}
export default OTPVerificationEmail
@@ -0,0 +1,36 @@
import { Link, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/ee/whitelabeling'
interface ResetPasswordEmailProps {
username?: string
resetLink?: string
}
export function ResetPasswordEmail({ username = '', resetLink = '' }: ResetPasswordEmailProps) {
const brand = getBrandConfig()
return (
<EmailLayout preview={`Reset your ${brand.name} password`} showUnsubscribe={false}>
<Text style={baseStyles.paragraph}>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>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
If you didn't request this, you can ignore this email. Link expires in 24 hours.
</Text>
</EmailLayout>
)
}
export default ResetPasswordEmail
@@ -0,0 +1,53 @@
import { Link, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getBrandConfig } from '@/ee/whitelabeling'
interface WelcomeEmailProps {
userName?: string
}
export function WelcomeEmail({ userName }: WelcomeEmailProps) {
const brand = getBrandConfig()
const baseUrl = getBaseUrl()
return (
<EmailLayout preview={`Welcome to ${brand.name}`} showUnsubscribe={false}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{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>
<Text style={baseStyles.paragraph}>
If you have any questions or feedback, just reply to this email. I read every message!
</Text>
<Text style={baseStyles.paragraph}>
Want to chat?{' '}
<Link href={`${baseUrl}/team`} style={baseStyles.link}>
Schedule a call
</Link>{' '}
with our team.
</Text>
<Text style={baseStyles.paragraph}>- Emir, co-founder of {brand.name}</Text>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
You're on the Community plan with 1,000 credits to get started.
</Text>
</EmailLayout>
)
}
export default WelcomeEmail
@@ -0,0 +1,33 @@
import { Body, Head, Html, Preview, Text } from '@react-email/components'
import { plainEmailStyles as styles } from '@/components/emails/_styles'
interface AbandonedCheckoutEmailProps {
userName?: string
}
export function AbandonedCheckoutEmail({ userName }: AbandonedCheckoutEmailProps) {
return (
<Html>
<Head />
<Preview>Did you run into an issue with your upgrade?</Preview>
<Body style={styles.body}>
<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&apos;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.
</Text>
<Text style={styles.p}>
Emir
<br />
Founder, Sim
</Text>
</div>
</Body>
</Html>
)
}
export default AbandonedCheckoutEmail
@@ -0,0 +1,7 @@
/** Pro plan features shown in billing upgrade emails */
export const proFeatures = [
{ label: '6,000 credits/month', desc: 'included' },
{ label: '+50 daily refresh', desc: 'credits per day' },
{ label: '150 runs/min', desc: 'sync executions' },
{ label: '50GB storage', desc: 'for files & assets' },
] as const
@@ -0,0 +1,99 @@
import { Link, Section, Text } from '@react-email/components'
import { baseStyles, colors } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getBrandConfig } from '@/ee/whitelabeling'
interface CreditPurchaseEmailProps {
userName?: string
amount: number
newBalance: number
purchaseDate?: Date
}
export function CreditPurchaseEmail({
userName,
amount,
newBalance,
purchaseDate = new Date(),
}: CreditPurchaseEmailProps) {
const brand = getBrandConfig()
const baseUrl = getBaseUrl()
const previewText = `${brand.name}: ${dollarsToCredits(amount).toLocaleString()} credits added to your account`
return (
<EmailLayout preview={previewText} showUnsubscribe={false}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text style={baseStyles.paragraph}>
Your credit purchase of <strong>{dollarsToCredits(amount).toLocaleString()} credits</strong>{' '}
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,
}}
>
{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,
}}
>
{dollarsToCredits(newBalance).toLocaleString()} credits
</Text>
</Section>
<Text style={baseStyles.paragraph}>
Credits are applied automatically to your workflow executions.
</Text>
<Link href={`${baseUrl}/workspace`} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>View Dashboard</Text>
</Link>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Purchased on {purchaseDate.toLocaleDateString()}. View balance in Settings Subscription.
</Text>
</EmailLayout>
)
}
export default CreditPurchaseEmail
@@ -0,0 +1,102 @@
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 { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { getBrandConfig } from '@/ee/whitelabeling'
interface CreditsExhaustedEmailProps {
userName?: string
limit: number
upgradeLink: string
}
export function CreditsExhaustedEmail({
userName,
limit,
upgradeLink,
}: CreditsExhaustedEmailProps) {
const brand = getBrandConfig()
return (
<EmailLayout
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.paragraph}>
You&apos;ve used all <strong>{dollarsToCredits(limit).toLocaleString()}</strong> 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>
<Link href={upgradeLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Upgrade to Pro</Text>
</Link>
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
One-time notification when free credits are exhausted.
</Text>
</EmailLayout>
)
}
export default CreditsExhaustedEmail
@@ -0,0 +1,54 @@
import { Link, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getBrandConfig } from '@/ee/whitelabeling'
interface EnterpriseSubscriptionEmailProps {
userName?: string
loginLink?: string
}
export function EnterpriseSubscriptionEmail({
userName = 'Valued User',
loginLink,
}: EnterpriseSubscriptionEmailProps) {
const brand = getBrandConfig()
const baseUrl = getBaseUrl()
const effectiveLoginLink = loginLink || `${baseUrl}/login`
return (
<EmailLayout
preview={`Your Enterprise Plan is now active on ${brand.name}`}
showUnsubscribe={false}
>
<Text style={baseStyles.paragraph}>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.
</Text>
<Link href={effectiveLoginLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Open {brand.name}</Text>
</Link>
<Text style={baseStyles.paragraph}>
<strong>Next steps:</strong>
<br /> Invite teammates to your organization
<br /> Start building your workflows
</Text>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Questions? Reply to this email or contact us at{' '}
<Link href={`mailto:${brand.supportEmail}`} style={baseStyles.link}>
{brand.supportEmail}
</Link>
</Text>
</EmailLayout>
)
}
export default EnterpriseSubscriptionEmail
@@ -0,0 +1,108 @@
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 { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { getBrandConfig } from '@/ee/whitelabeling'
interface FreeTierUpgradeEmailProps {
userName?: string
percentUsed: number
currentUsage: number
limit: number
upgradeLink: string
}
export function FreeTierUpgradeEmail({
userName,
percentUsed,
currentUsage,
limit,
upgradeLink,
}: FreeTierUpgradeEmailProps) {
const brand = getBrandConfig()
const previewText = `${brand.name}: You've used ${percentUsed}% of your free credits`
return (
<EmailLayout preview={previewText} showUnsubscribe={true}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{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.
</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>
<Link href={upgradeLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Upgrade to Pro</Text>
</Link>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
One-time notification at 80% usage.
</Text>
</EmailLayout>
)
}
export default FreeTierUpgradeEmail
@@ -0,0 +1,9 @@
export { AbandonedCheckoutEmail } from './abandoned-checkout-email'
export { CreditPurchaseEmail } from './credit-purchase-email'
export { CreditsExhaustedEmail } from './credits-exhausted-email'
export { EnterpriseSubscriptionEmail } from './enterprise-subscription-email'
export { FreeTierUpgradeEmail } from './free-tier-upgrade-email'
export { LimitThresholdEmail } from './limit-threshold-email'
export { PaymentFailedEmail } from './payment-failed-email'
export { PlanWelcomeEmail } from './plan-welcome-email'
export { UsageThresholdEmail } from './usage-threshold-email'
@@ -0,0 +1,76 @@
import { Link, Section, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { UPGRADE_REASON_COPY, type UpgradeReason } from '@/lib/billing/upgrade-reasons'
import { getBrandConfig } from '@/ee/whitelabeling'
interface LimitThresholdEmailProps {
/** `warning` = approaching the limit (~80%); `reached` = at/over the limit. */
kind: 'warning' | 'reached'
/** Limit category, drives the shared copy. */
reason: UpgradeReason
userName?: string
/** Pre-formatted current usage, e.g. "4.2 GB", "48,000 rows", "9 seats". */
usageLabel: string
/** Pre-formatted limit, e.g. "5 GB", "50,000 rows", "10 seats". */
limitLabel: string
percentUsed: number
upgradeLink: string
}
/**
* Single template for the per-category usage-limit emails (storage, tables,
* seats). Copy comes from {@link UPGRADE_REASON_COPY} so the email language
* matches the upgrade-page header the user lands on.
*/
export function LimitThresholdEmail({
kind,
reason,
userName,
usageLabel,
limitLabel,
percentUsed,
upgradeLink,
}: LimitThresholdEmailProps) {
const brand = getBrandConfig()
const copy = UPGRADE_REASON_COPY[reason]
const lead = kind === 'reached' ? copy.reachedLead : copy.warningLead
const previewText = `${brand.name}: ${lead}`
return (
<EmailLayout preview={previewText} showUnsubscribe={true}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text style={baseStyles.paragraph}>
{lead} Upgrade your plan for more {copy.noun}.
</Text>
<Section style={baseStyles.infoBox}>
<Text style={baseStyles.infoBoxTitle}>Usage</Text>
<Text style={baseStyles.infoBoxList}>
{usageLabel} of {limitLabel} used ({percentUsed}%)
</Text>
</Section>
{/* Divider */}
<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' }}>
{kind === 'reached'
? 'One-time notification at 100% usage.'
: 'One-time notification at 80% usage.'}
</Text>
</EmailLayout>
)
}
export default LimitThresholdEmail
@@ -0,0 +1,109 @@
import { Link, Section, Text } from '@react-email/components'
import { baseStyles, colors } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/ee/whitelabeling'
interface PaymentFailedEmailProps {
userName?: string
amountDue: number
lastFourDigits?: string
billingPortalUrl: string
failureReason?: string
sentDate?: Date
}
export function PaymentFailedEmail({
userName,
amountDue,
lastFourDigits,
billingPortalUrl,
failureReason,
sentDate = new Date(),
}: PaymentFailedEmailProps) {
const brand = getBrandConfig()
const previewText = `${brand.name}: Payment Failed - Action Required`
return (
<EmailLayout preview={previewText} showUnsubscribe={false}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text
style={{
...baseStyles.paragraph,
fontSize: '16px',
fontWeight: 600,
color: colors.textPrimary,
}}
>
We were unable to process your payment.
</Text>
<Text style={baseStyles.paragraph}>
Your {brand.name} account has been temporarily blocked to prevent service interruptions and
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' }}>
Amount due: ${amountDue.toFixed(2)}
</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>
{/* 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' }}>
Common issues: expired card, insufficient funds, or incorrect billing info. Need help?{' '}
<Link href={`mailto:${brand.supportEmail}`} style={baseStyles.link}>
{brand.supportEmail}
</Link>
</Text>
</EmailLayout>
)
}
export default PaymentFailedEmail
@@ -0,0 +1,52 @@
import { Link, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getBrandConfig } from '@/ee/whitelabeling'
interface PlanWelcomeEmailProps {
planName: string
userName?: string
loginLink?: string
}
export function PlanWelcomeEmail({ planName, userName, loginLink }: PlanWelcomeEmailProps) {
const brand = getBrandConfig()
const baseUrl = getBaseUrl()
const cta = loginLink || `${baseUrl}/login`
const previewText = `${brand.name}: Your ${planName} plan is active`
return (
<EmailLayout preview={previewText} showUnsubscribe={true}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{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.
</Text>
<Link href={cta} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Open {brand.name}</Text>
</Link>
<Text style={baseStyles.paragraph}>
Want help getting started?{' '}
<Link href={`${baseUrl}/team`} style={baseStyles.link}>
Schedule a call
</Link>{' '}
with our team.
</Text>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Manage your subscription in Settings Subscription.
</Text>
</EmailLayout>
)
}
export default PlanWelcomeEmail
@@ -0,0 +1,67 @@
import { Link, Section, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { getBrandConfig } from '@/ee/whitelabeling'
interface UsageThresholdEmailProps {
userName?: string
planName: string
percentUsed: number
currentUsage: number
limit: number
ctaLink: string
}
export function UsageThresholdEmail({
userName,
planName,
percentUsed,
currentUsage,
limit,
ctaLink,
}: UsageThresholdEmailProps) {
const brand = getBrandConfig()
const previewText = `${brand.name}: You're at ${percentUsed}% of your ${planName} monthly budget`
return (
<EmailLayout preview={previewText} showUnsubscribe={true}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text style={baseStyles.paragraph}>
You're approaching your monthly budget on the {planName} plan.
</Text>
<Section style={baseStyles.infoBox}>
<Text style={baseStyles.infoBoxTitle}>Usage</Text>
<Text style={baseStyles.infoBoxList}>
{dollarsToCredits(currentUsage).toLocaleString()} of{' '}
{dollarsToCredits(limit).toLocaleString()} credits used ({percentUsed}%)
</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>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
One-time notification at 80% usage.
</Text>
</EmailLayout>
)
}
export default UsageThresholdEmail
@@ -0,0 +1,253 @@
import { Container, Img, Link, Section } from '@react-email/components'
import { baseStyles, colors, spacing, typography } from '@/components/emails/_styles'
import { isHosted } from '@/lib/core/config/env-flags'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getBrandConfig } from '@/ee/whitelabeling'
interface EmailFooterProps {
baseUrl?: string
messageId?: string
/**
* Whether to show unsubscribe link. Defaults to true.
* Set to false for transactional emails where unsubscribe doesn't apply.
*/
showUnsubscribe?: boolean
}
/**
* Email footer component styled to match Stripe's email design.
* Sits in the gray area below the main white card.
*
* For non-transactional emails, the unsubscribe link uses placeholders
* {{UNSUBSCRIBE_TOKEN}} and {{UNSUBSCRIBE_EMAIL}} which are replaced
* by the mailer when sending.
*/
export function EmailFooter({
baseUrl = getBaseUrl(),
messageId,
showUnsubscribe = true,
}: EmailFooterProps) {
const brand = getBrandConfig()
const isWhitelabeled = brand.isWhitelabeled
const footerLinkStyle = {
color: colors.textMuted,
textDecoration: 'underline',
fontWeight: 'normal' as const,
fontFamily: typography.fontFamily,
}
return (
<Section
style={{
backgroundColor: colors.footerBg,
width: '100%',
}}
>
<Container style={{ maxWidth: `${spacing.containerWidth}px`, margin: '0 auto' }}>
<table
cellPadding={0}
cellSpacing={0}
border={0}
width='100%'
style={{ minWidth: `${spacing.containerWidth}px` }}
>
<tbody>
<tr>
<td style={baseStyles.spacer} height={32}>
&nbsp;
</td>
</tr>
{/* Social links row — hidden for whitelabeled instances */}
{!isWhitelabeled && (
<>
<tr>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
<td>
<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'
/>
</Link>
</td>
<td align='left' style={{ padding: '0 8px' }}>
<Link
href='https://www.linkedin.com/company/simdotai'
rel='noopener noreferrer'
>
<Img
src={`${baseUrl}/static/linkedin-icon.png`}
width='20'
height='20'
alt='LinkedIn'
/>
</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'
/>
</Link>
</td>
</tr>
</tbody>
</table>
</td>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
</tr>
<tr>
<td style={baseStyles.spacer} height={16}>
&nbsp;
</td>
</tr>
</>
)}
{/* Address row — hidden for whitelabeled instances */}
{!isWhitelabeled && (
<>
<tr>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
<td style={baseStyles.footerText}>
{brand.name}
{isHosted && <>, 80 Langton St, San Francisco, CA 94103, USA</>}
</td>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
</tr>
<tr>
<td style={baseStyles.spacer} height={8}>
&nbsp;
</td>
</tr>
</>
)}
{/* Contact row */}
<tr>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
<td style={baseStyles.footerText}>
Questions?{' '}
<a href={`mailto:${brand.supportEmail}`} style={footerLinkStyle}>
{brand.supportEmail}
</a>
</td>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
</tr>
<tr>
<td style={baseStyles.spacer} height={8}>
&nbsp;
</td>
</tr>
{/* Message ID row (optional) */}
{messageId && (
<>
<tr>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
<td style={baseStyles.footerText}>
Need to refer to this message? Use this ID: {messageId}
</td>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
</tr>
<tr>
<td style={baseStyles.spacer} height={8}>
&nbsp;
</td>
</tr>
</>
)}
{/* Links row */}
<tr>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
<td style={baseStyles.footerText}>
<a href={`${baseUrl}/privacy`} style={footerLinkStyle} rel='noopener noreferrer'>
Privacy Policy
</a>{' '}
{' '}
<a href={`${baseUrl}/terms`} style={footerLinkStyle} rel='noopener noreferrer'>
Terms of Service
</a>
{showUnsubscribe && (
<>
{' '}
{' '}
<a
href={`${baseUrl}/unsubscribe?token={{UNSUBSCRIBE_TOKEN}}&email={{UNSUBSCRIBE_EMAIL}}`}
style={footerLinkStyle}
rel='noopener noreferrer'
>
Unsubscribe
</a>
</>
)}
</td>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
</tr>
{/* Copyright row */}
<tr>
<td style={baseStyles.spacer} height={16}>
&nbsp;
</td>
</tr>
<tr>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
<td style={baseStyles.footerText}>
© {new Date().getFullYear()} {brand.name}, All Rights Reserved
</td>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
</tr>
<tr>
<td style={baseStyles.spacer} height={32}>
&nbsp;
</td>
</tr>
</tbody>
</table>
</Container>
</Section>
)
}
export default EmailFooter
@@ -0,0 +1,75 @@
import { Body, Container, Font, Head, Html, Img, Preview, Section } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailFooter } from '@/components/emails/components/email-footer'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getBrandConfig } from '@/ee/whitelabeling'
interface EmailLayoutProps {
/** Preview text shown in email client list view */
preview: string
/** Email content to render inside the layout */
children: React.ReactNode
/** Optional: hide footer for internal emails */
hideFooter?: boolean
/**
* Whether to show unsubscribe link in footer.
* Set to false for transactional emails where unsubscribe doesn't apply.
*/
showUnsubscribe: boolean
}
/**
* Shared email layout wrapper providing consistent structure.
* Includes Html, Head, Body, Container with logo header, and Footer.
*/
export function EmailLayout({
preview,
children,
hideFooter = false,
showUnsubscribe,
}: EmailLayoutProps) {
const brand = getBrandConfig()
const baseUrl = getBaseUrl()
const hasCustomLogo = Boolean(brand.logoUrl)
return (
<Html>
<Head>
<Font
fontFamily='Season Sans'
fallbackFontFamily={['Helvetica', 'sans-serif']}
webFont={{
url: `${baseUrl}/brand/fonts/SeasonSansUprightsVF.woff2`,
format: 'woff2',
}}
fontWeight='300 800'
fontStyle='normal'
/>
</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`}
height='34'
{...(hasCustomLogo ? {} : { width: '70' })}
alt={brand.name}
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>
)
}
export default EmailLayout
@@ -0,0 +1,2 @@
export { EmailFooter } from './email-footer'
export { EmailLayout } from './email-layout'
+15
View File
@@ -0,0 +1,15 @@
// Styles
export * from './_styles'
// Auth emails
export * from './auth'
// Billing emails
export * from './billing'
// Shared components
export * from './components'
// Invitation emails
export * from './invitations'
// Render functions and subjects
export * from './render'
export * from './subjects'
// Support emails
export * from './support'
@@ -0,0 +1,108 @@
import { Link, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/ee/whitelabeling'
interface WorkspaceInvitation {
workspaceId: string
workspaceName: string
permission: 'admin' | 'write' | 'read'
}
interface BatchInvitationEmailProps {
inviterName: string
organizationName: string
organizationRole: 'admin' | 'member'
workspaceInvitations: WorkspaceInvitation[]
acceptUrl: string
}
const getPermissionLabel = (permission: string) => {
switch (permission) {
case 'admin':
return 'Admin (full access)'
case 'write':
return 'Editor (can edit workflows)'
case 'read':
return 'Viewer (read-only access)'
default:
return permission
}
}
const getRoleLabel = (role: string) => {
switch (role) {
case 'admin':
return 'Admin'
case 'member':
return 'Member'
default:
return role
}
}
const EMPTY_INVITATIONS: WorkspaceInvitation[] = []
export function BatchInvitationEmail({
inviterName = 'Someone',
organizationName = 'the team',
organizationRole = 'member',
workspaceInvitations = EMPTY_INVITATIONS,
acceptUrl,
}: BatchInvitationEmailProps) {
const brand = getBrandConfig()
const hasWorkspaces = workspaceInvitations.length > 0
return (
<EmailLayout
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.paragraph}>
<strong>{inviterName}</strong> has invited you to join <strong>{organizationName}</strong>{' '}
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>
{/* Workspace Invitations */}
{hasWorkspaces && (
<>
<Text style={baseStyles.paragraph}>
<strong>
Workspace Access ({workspaceInvitations.length} workspace
{workspaceInvitations.length !== 1 ? 's' : ''}):
</strong>
</Text>
{workspaceInvitations.map((ws) => (
<Text key={ws.workspaceId} style={{ ...baseStyles.paragraph, marginLeft: '20px' }}>
<strong>{ws.workspaceName}</strong> - {getPermissionLabel(ws.permission)}
</Text>
))}
</>
)}
<Link href={acceptUrl} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Accept Invitation</Text>
</Link>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Invitation expires in 7 days. If unexpected, you can ignore this email.
</Text>
</EmailLayout>
)
}
export default BatchInvitationEmail
@@ -0,0 +1,4 @@
export { BatchInvitationEmail } from './batch-invitation-email'
export { InvitationEmail } from './invitation-email'
export { WorkspaceAddedEmail } from './workspace-added-email'
export { WorkspaceInvitationEmail } from './workspace-invitation-email'
@@ -0,0 +1,63 @@
import { Link, Text } from '@react-email/components'
import { createLogger } from '@sim/logger'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getBrandConfig } from '@/ee/whitelabeling'
interface InvitationEmailProps {
inviterName?: string
organizationName?: string
inviteLink?: string
}
const logger = createLogger('InvitationEmail')
export function InvitationEmail({
inviterName = 'A team member',
organizationName = 'an organization',
inviteLink = '',
}: InvitationEmailProps) {
const brand = getBrandConfig()
const baseUrl = getBaseUrl()
let enhancedLink = inviteLink
if (inviteLink && !inviteLink.includes('token=')) {
try {
const url = new URL(inviteLink)
const invitationId = url.pathname.split('/').pop()
if (invitationId) {
enhancedLink = `${baseUrl}/invite/${invitationId}?token=${invitationId}`
}
} catch (e) {
logger.error('Error parsing invite link:', e)
}
}
return (
<EmailLayout
preview={`You've been invited to join ${organizationName} on ${brand.name}`}
showUnsubscribe={false}
>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>
<strong>{inviterName}</strong> invited you to join <strong>{organizationName}</strong> on{' '}
{brand.name}.
</Text>
<Link href={enhancedLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Accept Invitation</Text>
</Link>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Invitation expires in 48 hours. If unexpected, you can ignore this email.
</Text>
</EmailLayout>
)
}
export default InvitationEmail
@@ -0,0 +1,44 @@
import { Link, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/ee/whitelabeling'
interface WorkspaceAddedEmailProps {
/** Name of the workspace the recipient was added to. */
workspaceName?: string
/** Name of the person who added the recipient. */
inviterName?: string
/** Direct link to the workspace (no acceptance required). */
workspaceLink?: string
}
export function WorkspaceAddedEmail({
workspaceName = 'Workspace',
inviterName = 'Someone',
workspaceLink = '',
}: WorkspaceAddedEmailProps) {
const brand = getBrandConfig()
const preview = `You've been added to the "${workspaceName}" workspace on ${brand.name}`
return (
<EmailLayout preview={preview} showUnsubscribe={false}>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>
<strong>{inviterName}</strong> added you to the <strong>{workspaceName}</strong> workspace
on {brand.name}.
</Text>
<Link href={workspaceLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Open workspace</Text>
</Link>
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
If this was unexpected, contact a workspace admin.
</Text>
</EmailLayout>
)
}
export default WorkspaceAddedEmail
@@ -0,0 +1,56 @@
import { Link, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/ee/whitelabeling'
interface WorkspaceInvitationEmailProps {
/** Workspaces this invitation grants access to (one entry per workspace). */
workspaceNames?: string[]
inviterName?: string
invitationLink?: string
}
export function WorkspaceInvitationEmail({
workspaceNames = ['Workspace'],
inviterName = 'Someone',
invitationLink = '',
}: WorkspaceInvitationEmailProps) {
const brand = getBrandConfig()
const isMultiple = workspaceNames.length > 1
const preview = isMultiple
? `You've been invited to join ${workspaceNames.length} workspaces on ${brand.name}!`
: `You've been invited to join the "${workspaceNames[0]}" workspace on ${brand.name}!`
return (
<EmailLayout preview={preview} showUnsubscribe={false}>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>
<strong>{inviterName}</strong> invited you to join the{' '}
{workspaceNames.map((name, index) => (
<span key={`${name}-${index}`}>
{index > 0 &&
(index === workspaceNames.length - 1
? workspaceNames.length > 2
? ', and '
: ' and '
: ', ')}
<strong>{name}</strong>
</span>
))}{' '}
{isMultiple ? 'workspaces' : 'workspace'} on {brand.name}.
</Text>
<Link href={invitationLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Accept Invitation</Text>
</Link>
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Invitation expires in 7 days. If unexpected, you can ignore this email.
</Text>
</EmailLayout>
)
}
export default WorkspaceInvitationEmail
+262
View File
@@ -0,0 +1,262 @@
import { render } from '@react-email/render'
import {
ExistingAccountEmail,
OnboardingFollowupEmail,
OTPVerificationEmail,
ResetPasswordEmail,
WelcomeEmail,
} from '@/components/emails/auth'
import {
AbandonedCheckoutEmail,
CreditPurchaseEmail,
CreditsExhaustedEmail,
EnterpriseSubscriptionEmail,
FreeTierUpgradeEmail,
LimitThresholdEmail,
PaymentFailedEmail,
PlanWelcomeEmail,
UsageThresholdEmail,
} from '@/components/emails/billing'
import {
BatchInvitationEmail,
InvitationEmail,
WorkspaceAddedEmail,
WorkspaceInvitationEmail,
} from '@/components/emails/invitations'
import { HelpConfirmationEmail } from '@/components/emails/support'
import type { UpgradeReason } from '@/lib/billing/upgrade-reasons'
import { getBaseUrl } from '@/lib/core/utils/urls'
export { getEmailSubject, getLimitEmailSubject } from './subjects'
interface WorkspaceInvitation {
workspaceId: string
workspaceName: string
permission: 'admin' | 'write' | 'read'
}
export async function renderOTPEmail(
otp: string,
email: string,
type:
| 'sign-in'
| 'email-verification'
| 'change-email'
| 'forget-password' = 'email-verification',
chatTitle?: string
): Promise<string> {
return await render(OTPVerificationEmail({ otp, email, type, chatTitle }))
}
export async function renderExistingAccountEmail(username: string): Promise<string> {
return await render(ExistingAccountEmail({ username }))
}
export async function renderPasswordResetEmail(
username: string,
resetLink: string
): Promise<string> {
return await render(ResetPasswordEmail({ username, resetLink }))
}
export async function renderInvitationEmail(
inviterName: string,
organizationName: string,
invitationUrl: string
): Promise<string> {
return await render(
InvitationEmail({
inviterName,
organizationName,
inviteLink: invitationUrl,
})
)
}
export async function renderBatchInvitationEmail(
inviterName: string,
organizationName: string,
organizationRole: 'admin' | 'member',
workspaceInvitations: WorkspaceInvitation[],
acceptUrl: string
): Promise<string> {
return await render(
BatchInvitationEmail({
inviterName,
organizationName,
organizationRole,
workspaceInvitations,
acceptUrl,
})
)
}
export async function renderHelpConfirmationEmail(
type: 'bug' | 'feedback' | 'feature_request' | 'other',
attachmentCount = 0
): Promise<string> {
return await render(
HelpConfirmationEmail({
type,
attachmentCount,
submittedDate: new Date(),
})
)
}
export async function renderEnterpriseSubscriptionEmail(userName: string): Promise<string> {
const baseUrl = getBaseUrl()
const loginLink = `${baseUrl}/login`
return await render(
EnterpriseSubscriptionEmail({
userName,
loginLink,
})
)
}
export async function renderUsageThresholdEmail(params: {
userName?: string
planName: string
percentUsed: number
currentUsage: number
limit: number
ctaLink: string
}): Promise<string> {
return await render(
UsageThresholdEmail({
userName: params.userName,
planName: params.planName,
percentUsed: params.percentUsed,
currentUsage: params.currentUsage,
limit: params.limit,
ctaLink: params.ctaLink,
})
)
}
export async function renderFreeTierUpgradeEmail(params: {
userName?: string
percentUsed: number
currentUsage: number
limit: number
upgradeLink: string
}): Promise<string> {
return await render(
FreeTierUpgradeEmail({
userName: params.userName,
percentUsed: params.percentUsed,
currentUsage: params.currentUsage,
limit: params.limit,
upgradeLink: params.upgradeLink,
})
)
}
export async function renderLimitThresholdEmail(params: {
kind: 'warning' | 'reached'
reason: UpgradeReason
userName?: string
usageLabel: string
limitLabel: string
percentUsed: number
upgradeLink: string
}): Promise<string> {
return await render(LimitThresholdEmail(params))
}
export async function renderPlanWelcomeEmail(params: {
planName: string
userName?: string
loginLink?: string
}): Promise<string> {
return await render(
PlanWelcomeEmail({
planName: params.planName,
userName: params.userName,
loginLink: params.loginLink,
})
)
}
export async function renderWelcomeEmail(userName?: string): Promise<string> {
return await render(WelcomeEmail({ userName }))
}
export async function renderOnboardingFollowupEmail(userName?: string): Promise<string> {
return await render(OnboardingFollowupEmail({ userName }))
}
export async function renderAbandonedCheckoutEmail(userName?: string): Promise<string> {
return await render(AbandonedCheckoutEmail({ userName }))
}
export async function renderCreditsExhaustedEmail(params: {
userName?: string
limit: number
upgradeLink: string
}): Promise<string> {
return await render(CreditsExhaustedEmail(params))
}
export async function renderCreditPurchaseEmail(params: {
userName?: string
amount: number
newBalance: number
}): Promise<string> {
return await render(
CreditPurchaseEmail({
userName: params.userName,
amount: params.amount,
newBalance: params.newBalance,
purchaseDate: new Date(),
})
)
}
export async function renderWorkspaceInvitationEmail(
inviterName: string,
workspaceNames: string[],
invitationLink: string
): Promise<string> {
return await render(
WorkspaceInvitationEmail({
inviterName,
workspaceNames,
invitationLink,
})
)
}
export async function renderWorkspaceAddedEmail(
inviterName: string,
workspaceName: string,
workspaceLink: string
): Promise<string> {
return await render(
WorkspaceAddedEmail({
inviterName,
workspaceName,
workspaceLink,
})
)
}
export async function renderPaymentFailedEmail(params: {
userName?: string
amountDue: number
lastFourDigits?: string
billingPortalUrl: string
failureReason?: string
}): Promise<string> {
return await render(
PaymentFailedEmail({
userName: params.userName,
amountDue: params.amountDue,
lastFourDigits: params.lastFourDigits,
billingPortalUrl: params.billingPortalUrl,
failureReason: params.failureReason,
})
)
}
+91
View File
@@ -0,0 +1,91 @@
import { UPGRADE_REASON_COPY, type UpgradeReason } from '@/lib/billing/upgrade-reasons'
import { getBrandConfig } from '@/ee/whitelabeling'
/** Email subject type for all supported email templates */
export type EmailSubjectType =
| 'sign-in'
| 'email-verification'
| 'change-email'
| 'forget-password'
| 'reset-password'
| 'existing-account'
| 'invitation'
| 'batch-invitation'
| 'workspace-added'
| 'help-confirmation'
| 'enterprise-subscription'
| 'usage-threshold'
| 'free-tier-upgrade'
| 'plan-welcome-pro'
| 'plan-welcome-team'
| 'credit-purchase'
| 'abandoned-checkout'
| 'free-tier-exhausted'
| 'onboarding-followup'
| 'welcome'
/**
* Returns the email subject line for a given email type.
* @param type - The type of email being sent
* @returns The subject line for the email
*/
export function getEmailSubject(type: EmailSubjectType): string {
const brandName = getBrandConfig().name
switch (type) {
case 'sign-in':
return `Sign in to ${brandName}`
case 'email-verification':
return `Verify your email for ${brandName}`
case 'change-email':
return `Verify your new email for ${brandName}`
case 'forget-password':
return `Reset your ${brandName} password`
case 'reset-password':
return `Reset your ${brandName} password`
case 'existing-account':
return `Sign-up attempt with your ${brandName} email`
case 'invitation':
return `You've been invited to join a team on ${brandName}`
case 'batch-invitation':
return `You've been invited to join a team and workspaces on ${brandName}`
case 'workspace-added':
return `You've been added to a workspace on ${brandName}`
case 'help-confirmation':
return 'Your request has been received'
case 'enterprise-subscription':
return `Your Enterprise Plan is now active on ${brandName}`
case 'usage-threshold':
return `You're nearing your monthly budget on ${brandName}`
case 'free-tier-upgrade':
return `You're at 80% of your free credits on ${brandName}`
case 'plan-welcome-pro':
return `Your Pro plan is now active on ${brandName}`
case 'plan-welcome-team':
return `Your Team plan is now active on ${brandName}`
case 'credit-purchase':
return `Credits added to your ${brandName} account`
case 'abandoned-checkout':
return `Quick question`
case 'free-tier-exhausted':
return `You've run out of free credits on ${brandName}`
case 'onboarding-followup':
return `Quick question about ${brandName}`
case 'welcome':
return `Welcome to ${brandName}`
default:
return brandName
}
}
/**
* Subject line for a per-category usage-limit email. Reuses the shared
* {@link UPGRADE_REASON_COPY} so the subject matches the email body and the
* upgrade-page header the user lands on.
*/
export function getLimitEmailSubject(reason: UpgradeReason, kind: 'warning' | 'reached'): string {
const brandName = getBrandConfig().name
const copy = UPGRADE_REASON_COPY[reason]
const subject = kind === 'reached' ? copy.reachedSubject : copy.warningSubject
return `${subject} on ${brandName}`
}
@@ -0,0 +1,61 @@
import { Text } from '@react-email/components'
import { format } from 'date-fns'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
interface HelpConfirmationEmailProps {
type?: 'bug' | 'feedback' | 'feature_request' | 'other'
attachmentCount?: number
submittedDate?: Date
}
const getTypeLabel = (type: string) => {
switch (type) {
case 'bug':
return 'Bug Report'
case 'feedback':
return 'Feedback'
case 'feature_request':
return 'Feature Request'
case 'other':
return 'General Inquiry'
default:
return 'Request'
}
}
export function HelpConfirmationEmail({
type = 'other',
attachmentCount = 0,
submittedDate = new Date(),
}: HelpConfirmationEmailProps) {
const typeLabel = getTypeLabel(type)
return (
<EmailLayout
preview={`Your ${typeLabel.toLowerCase()} has been received`}
showUnsubscribe={false}
>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>
We've received your <strong>{typeLabel.toLowerCase()}</strong> and will get back to you
shortly.
</Text>
{attachmentCount > 0 && (
<Text style={baseStyles.paragraph}>
{attachmentCount} image{attachmentCount > 1 ? 's' : ''} attached.
</Text>
)}
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Submitted on {format(submittedDate, 'MMMM do, yyyy')}.
</Text>
</EmailLayout>
)
}
export default HelpConfirmationEmail
@@ -0,0 +1 @@
export { HelpConfirmationEmail } from './help-confirmation-email'
File diff suppressed because one or more lines are too long
@@ -0,0 +1,293 @@
import type { SVGProps } from 'react'
import {
SUPPORTED_AUDIO_EXTENSIONS,
SUPPORTED_VIDEO_EXTENSIONS,
} from '@/lib/uploads/utils/validation'
export function PdfIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>
<rect x='4' y='2' width='16' height='20' rx='2' stroke='currentColor' strokeWidth='1.5' />
<text
x='12'
y='12'
textAnchor='middle'
dominantBaseline='central'
fontSize='5.5'
fontWeight='bold'
fontFamily='Arial, sans-serif'
letterSpacing='0.5'
fill='currentColor'
>
PDF
</text>
</svg>
)
}
export function DocxIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' />
<path d='M14 2v4a2 2 0 0 0 2 2h4' />
<path d='M16 9H8' />
<path d='M16 13H8' />
<path d='M16 17H8' />
</svg>
)
}
export function XlsxIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>
<rect x='3' y='3' width='18' height='18' rx='2' stroke='currentColor' strokeWidth='1.5' />
<line x1='3' y1='9' x2='21' y2='9' stroke='currentColor' strokeWidth='1.5' />
<line x1='3' y1='15' x2='21' y2='15' stroke='currentColor' strokeWidth='1.5' />
<line x1='9' y1='3' x2='9' y2='21' stroke='currentColor' strokeWidth='1.5' />
<line x1='15' y1='3' x2='15' y2='21' stroke='currentColor' strokeWidth='1.5' />
</svg>
)
}
export function CsvIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>
<rect x='3' y='1' width='8' height='6' rx='1.5' stroke='currentColor' strokeWidth='1.5' />
<rect x='13' y='1' width='8' height='6' rx='1.5' stroke='currentColor' strokeWidth='1.5' />
<rect x='3' y='9' width='8' height='6' rx='1.5' stroke='currentColor' strokeWidth='1.5' />
<rect x='13' y='9' width='8' height='6' rx='1.5' stroke='currentColor' strokeWidth='1.5' />
<rect x='3' y='17' width='8' height='6' rx='1.5' stroke='currentColor' strokeWidth='1.5' />
<rect x='13' y='17' width='8' height='6' rx='1.5' stroke='currentColor' strokeWidth='1.5' />
</svg>
)
}
export function TxtIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' />
<path d='M14 2v4a2 2 0 0 0 2 2h4' />
<path d='M16 13H8' />
<path d='M12 17H8' />
</svg>
)
}
export function PptxIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<rect x='2' y='4' width='20' height='16' rx='2' />
<line x1='6' y1='9' x2='18' y2='9' />
<line x1='8' y1='14' x2='16' y2='14' />
</svg>
)
}
export function AudioIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<line x1='4' y1='14' x2='4' y2='10' />
<line x1='8' y1='17' x2='8' y2='7' />
<line x1='12' y1='15' x2='12' y2='9' />
<line x1='16' y1='18' x2='16' y2='6' />
<line x1='20' y1='14' x2='20' y2='10' />
</svg>
)
}
export function VideoIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>
<rect x='2' y='4' width='20' height='16' rx='2' stroke='currentColor' strokeWidth='1.5' />
<path d='M10 9l5 3-5 3V9Z' fill='currentColor' />
</svg>
)
}
export function HtmlIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path d='M8 8l-4 4 4 4' />
<path d='M16 8l4 4-4 4' />
<line x1='14' y1='4' x2='10' y2='20' />
</svg>
)
}
export function JsonIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path d='M8 3H7a2 2 0 0 0-2 2v4c0 1.1-.9 2-2 2 1.1 0 2 .9 2 2v4a2 2 0 0 0 2 2h1' />
<path d='M16 3h1a2 2 0 0 1 2 2v4c0 1.1.9 2 2 2-1.1 0-2 .9-2 2v4a2 2 0 0 1-2 2h-1' />
</svg>
)
}
export function MarkdownIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>
<rect x='2' y='4' width='20' height='16' rx='3' stroke='currentColor' strokeWidth='1.5' />
<path
d='M6 15V9l3 3.5L12 9v6'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M17 9v6m-2-2l2 2 2-2'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
)
}
export function DefaultFileIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' />
<path d='M14 2v4a2 2 0 0 0 2 2h4' />
</svg>
)
}
export function getDocumentIcon(
mimeType: string,
filename: string
): (props: SVGProps<SVGSVGElement>) => React.JSX.Element {
const extension = filename.split('.').pop()?.toLowerCase()
if (
mimeType.startsWith('audio/') ||
(extension &&
SUPPORTED_AUDIO_EXTENSIONS.includes(extension as (typeof SUPPORTED_AUDIO_EXTENSIONS)[number]))
) {
return AudioIcon
}
if (
mimeType.startsWith('video/') ||
(extension &&
SUPPORTED_VIDEO_EXTENSIONS.includes(extension as (typeof SUPPORTED_VIDEO_EXTENSIONS)[number]))
) {
return VideoIcon
}
if (mimeType === 'application/pdf' || extension === 'pdf') {
return PdfIcon
}
if (
mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
mimeType === 'application/msword' ||
extension === 'docx' ||
extension === 'doc'
) {
return DocxIcon
}
if (
mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
mimeType === 'application/vnd.ms-excel' ||
extension === 'xlsx' ||
extension === 'xls'
) {
return XlsxIcon
}
if (mimeType === 'text/csv' || extension === 'csv') {
return CsvIcon
}
if (mimeType === 'text/plain' || extension === 'txt') {
return TxtIcon
}
if (
mimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation' ||
mimeType === 'application/vnd.ms-powerpoint' ||
extension === 'pptx' ||
extension === 'ppt'
) {
return PptxIcon
}
if (mimeType === 'text/html' || extension === 'html' || extension === 'htm') {
return HtmlIcon
}
if (mimeType === 'application/json' || extension === 'json') {
return JsonIcon
}
if (mimeType === 'text/markdown' || extension === 'md' || extension === 'mdx') {
return MarkdownIcon
}
return DefaultFileIcon
}
+13
View File
@@ -0,0 +1,13 @@
export {
type OrgRole,
OrgRoleSelector,
PermissionSelector,
type PermissionType,
} from './permission-selector'
export {
type CredentialRoleSource,
credentialRoleLockReason,
RoleLockTooltip,
type WorkspaceRoleSource,
workspaceRoleLockReason,
} from './role-lock'
@@ -0,0 +1,78 @@
'use client'
import React from 'react'
import { ButtonGroup, ButtonGroupItem, cn } from '@sim/emcn'
import type { PermissionType } from '@/lib/workspaces/permissions/utils'
export type { PermissionType }
type SelectorSize = 'default' | 'compact'
interface PermissionSelectorProps {
value: PermissionType
onChange: (value: PermissionType) => void
disabled?: boolean
className?: string
size?: SelectorSize
}
const COMPACT_ITEM_CLASS = 'h-[22px] min-w-[38px] px-1.5 py-0 text-xs'
export const PermissionSelector = React.memo<PermissionSelectorProps>(
({ value, onChange, disabled = false, className, size = 'default' }) => {
const itemClass = size === 'compact' ? COMPACT_ITEM_CLASS : undefined
return (
<ButtonGroup
value={value}
onValueChange={(val) => onChange(val as PermissionType)}
disabled={disabled}
className={cn(disabled && 'cursor-not-allowed', className)}
>
<ButtonGroupItem value='read' className={itemClass} title='View only'>
Read
</ButtonGroupItem>
<ButtonGroupItem value='write' className={itemClass} title='Edit content'>
Write
</ButtonGroupItem>
<ButtonGroupItem value='admin' className={itemClass} title='Full access'>
Admin
</ButtonGroupItem>
</ButtonGroup>
)
}
)
PermissionSelector.displayName = 'PermissionSelector'
export type OrgRole = 'admin' | 'member'
interface OrgRoleSelectorProps {
value: OrgRole
onChange: (value: OrgRole) => void
disabled?: boolean
className?: string
size?: SelectorSize
}
const COMPACT_ORG_ITEM_CLASS = 'h-[22px] min-w-[58px] px-1.5 py-0 text-xs'
export const OrgRoleSelector = React.memo<OrgRoleSelectorProps>(
({ value, onChange, disabled = false, className, size = 'compact' }) => {
const itemClass = size === 'compact' ? COMPACT_ORG_ITEM_CLASS : undefined
return (
<ButtonGroup
value={value}
onValueChange={(val) => onChange(val as OrgRole)}
disabled={disabled}
className={cn(disabled && 'cursor-not-allowed', className)}
>
<ButtonGroupItem value='member' className={itemClass} title='Organization member'>
Member
</ButtonGroupItem>
<ButtonGroupItem value='admin' className={itemClass} title='Organization admin'>
Admin
</ButtonGroupItem>
</ButtonGroup>
)
}
)
OrgRoleSelector.displayName = 'OrgRoleSelector'
@@ -0,0 +1,54 @@
'use client'
import type { ReactNode } from 'react'
import { Tooltip } from '@sim/emcn'
export type WorkspaceRoleSource = 'owner' | 'explicit' | 'org-admin'
export type CredentialRoleSource = 'explicit' | 'workspace-admin'
/**
* Explanation shown when a workspace member's role is fixed by inheritance and
* cannot be edited. Returns null for editable (`explicit`) roles.
*/
export function workspaceRoleLockReason(
roleSource: WorkspaceRoleSource | undefined
): string | null {
if (roleSource === 'org-admin') return 'Organization admins are automatically workspace admins'
if (roleSource === 'owner') return 'Workspace owner'
return null
}
/**
* Explanation shown when a credential member's role is fixed because they are a
* workspace admin. Returns null for editable (`explicit`) roles.
*/
export function credentialRoleLockReason(
roleSource: CredentialRoleSource | undefined
): string | null {
if (roleSource === 'workspace-admin') {
return 'Workspace admins are automatically credential admins'
}
return null
}
interface RoleLockTooltipProps {
reason: string | null
children: ReactNode
}
/**
* Wraps a disabled role control in a tooltip explaining why the role is fixed.
* Renders children unchanged when there is no lock reason.
*/
export function RoleLockTooltip({ reason, children }: RoleLockTooltipProps) {
if (!reason) return <>{children}</>
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='inline-flex'>{children}</div>
</Tooltip.Trigger>
<Tooltip.Content>{reason}</Tooltip.Content>
</Tooltip.Root>
)
}
+49
View File
@@ -0,0 +1,49 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cn } from '@sim/emcn'
import { cva, type VariantProps } from 'class-variance-authority'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover-hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover-hover:bg-destructive/90',
outline:
'border border-input bg-background hover-hover:bg-accent hover-hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover-hover:bg-secondary/80',
ghost: 'hover-hover:bg-accent hover-hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover-hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }
@@ -0,0 +1,118 @@
'use client'
import { useEffect, useState } from 'react'
import { Button, ChipInput, Tooltip } from '@sim/emcn'
import { Check, Clipboard, Eye, EyeOff, RefreshCw } from 'lucide-react'
import { generatePassword } from '@/lib/core/security/encryption'
interface GeneratedPasswordInputProps {
value: string
onChange: (value: string) => void
disabled?: boolean
placeholder?: string
/** Show the Generate (random password) action. Off for consumer-facing entry forms. */
showGenerate?: boolean
required?: boolean
autoComplete?: string
error?: boolean
}
/**
* Password field with reveal / copy / (optional) generate adornments, used by the
* deploy-as-chat access controls and the file-share modal. Owns its show/copy UI
* state; the caller owns the value.
*/
export function GeneratedPasswordInput({
value,
onChange,
disabled = false,
placeholder,
showGenerate = true,
required = false,
autoComplete = 'new-password',
error = false,
}: GeneratedPasswordInputProps) {
const [showPassword, setShowPassword] = useState(false)
const [copySuccess, setCopySuccess] = useState(false)
useEffect(() => {
if (!copySuccess) return
const timer = setTimeout(() => setCopySuccess(false), 2000)
return () => clearTimeout(timer)
}, [copySuccess])
const copyToClipboard = () => {
navigator.clipboard.writeText(value)
setCopySuccess(true)
}
return (
<ChipInput
type={showPassword ? 'text' : 'password'}
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
required={required}
autoComplete={autoComplete}
error={error}
endAdornment={
<div className='flex items-center'>
{showGenerate ? (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
onClick={() => onChange(generatePassword(24))}
disabled={disabled}
aria-label='Generate password'
className='!p-1.5'
>
<RefreshCw className='size-3' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>Generate</span>
</Tooltip.Content>
</Tooltip.Root>
) : null}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
onClick={copyToClipboard}
disabled={!value || disabled}
aria-label='Copy password'
className='!p-1.5'
>
{copySuccess ? <Check className='size-3' /> : <Clipboard className='size-3' />}
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>{copySuccess ? 'Copied' : 'Copy'}</span>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
onClick={() => setShowPassword(!showPassword)}
disabled={disabled}
aria-label={showPassword ? 'Hide password' : 'Show password'}
className='!p-1.5'
>
{showPassword ? <EyeOff className='size-3' /> : <Eye className='size-3' />}
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>{showPassword ? 'Hide' : 'Show'}</span>
</Tooltip.Content>
</Tooltip.Root>
</div>
}
/>
)
}
+18
View File
@@ -0,0 +1,18 @@
export { Button, buttonVariants } from './button'
export { GeneratedPasswordInput } from './generated-password-input'
export { Progress } from './progress'
export { SearchHighlight } from './search-highlight'
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
} from './select'
export { ShimmerText } from './shimmer-text'
export { ThinkingLoader, type ThinkingLoaderVariant } from './thinking-loader'
+30
View File
@@ -0,0 +1,30 @@
'use client'
import * as React from 'react'
import * as ProgressPrimitive from '@radix-ui/react-progress'
import { cn } from '@sim/emcn'
interface ProgressProps extends React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> {
indicatorClassName?: string
}
const Progress = React.forwardRef<React.ElementRef<typeof ProgressPrimitive.Root>, ProgressProps>(
({ className, value, indicatorClassName, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-muted', className)}
{...props}
>
<ProgressPrimitive.Indicator
className={cn(
'h-full w-full flex-1 bg-primary transition-all dark:bg-white',
indicatorClassName
)}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
)
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }
@@ -0,0 +1,45 @@
interface SearchHighlightProps {
text: string
searchQuery: string
className?: string
}
export function SearchHighlight({ text, searchQuery, className = '' }: SearchHighlightProps) {
if (!searchQuery.trim()) {
return <span className={className}>{text}</span>
}
const searchTerms = searchQuery
.trim()
.split(/\s+/)
.filter((term) => term.length > 0)
.map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
if (searchTerms.length === 0) {
return <span className={className}>{text}</span>
}
const regex = new RegExp(`(${searchTerms.join('|')})`, 'gi')
const parts = text.split(regex)
return (
<span className={className}>
{parts.map((part, index) => {
if (!part) return null
const isMatch = searchTerms.some((term) => new RegExp(term, 'gi').test(part))
return isMatch ? (
<span
key={index}
className='bg-[var(--highlight-match-bg)] text-[var(--highlight-match-text)]'
>
{part}
</span>
) : (
<span key={index}>{part}</span>
)
})}
</span>
)
}
+158
View File
@@ -0,0 +1,158 @@
'use client'
import * as React from 'react'
import * as SelectPrimitive from '@radix-ui/react-select'
import { cn } from '@sim/emcn'
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between rounded-lg border border-input bg-input-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className='size-4 opacity-50 transition-transform duration-200 ease-in-out' />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
'sticky top-0 z-10 flex cursor-default items-center justify-center bg-popover py-1',
className
)}
{...props}
>
<ChevronUp className='size-4 opacity-70 transition-opacity hover-hover:opacity-100' />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
'sticky bottom-0 z-10 flex cursor-default items-center justify-center bg-popover py-1',
className
)}
{...props}
>
<ChevronDown className='size-4 opacity-70 transition-opacity hover-hover:opacity-100' />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in',
position === 'popper' &&
'data-[side=left]:-translate-x-1 data-[side=top]:-translate-y-1 data-[side=right]:translate-x-1 data-[side=bottom]:translate-y-1',
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('py-1.5 pr-2 pl-8 font-semibold text-sm', className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className='absolute left-2 flex size-3.5 items-center justify-center'>
<SelectPrimitive.ItemIndicator>
<Check className='size-4' />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
@@ -0,0 +1,44 @@
/**
* Claude-style text shimmer: the text paints from a gradient with a light band
* that sweeps across the glyphs via background-clip. This is the single source
* of truth for the treatment — the ThinkingLoader label composes it. Under
* reduced motion the sweep is replaced by a gentle opacity pulse in solid ink:
* the shimmer conveys essential in-progress state, so it needs a vestibular-safe
* fallback rather than none. Consumers whose resting text is not body ink set
* `--shimmer-rest` to their resting color.
*/
.shimmer {
background-image: linear-gradient(90deg, #4a4a4a 40%, #b0b0b0 50%, #4a4a4a 60%);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: shimmer-sweep 2.2s linear infinite;
}
:global(.dark) .shimmer {
background-image: linear-gradient(90deg, #b9b9b9 40%, #f8f8f8 50%, #b9b9b9 60%);
}
@keyframes shimmer-sweep {
to {
background-position: 200% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.shimmer,
:global(.dark) .shimmer {
background-image: none;
-webkit-background-clip: initial;
background-clip: initial;
color: var(--shimmer-rest, var(--text-body));
animation: shimmer-pulse 2.2s ease-in-out infinite;
}
}
@keyframes shimmer-pulse {
50% {
opacity: 0.55;
}
}
+17
View File
@@ -0,0 +1,17 @@
import { cn } from '@sim/emcn'
import styles from '@/components/ui/shimmer-text.module.css'
interface ShimmerTextProps {
children: React.ReactNode
className?: string
}
/**
* Sweeping-highlight shimmer over a text phrase — the same treatment as the
* ThinkingLoader's "Thinking…" label, reusable on any active/streaming row.
* Size and weight come from the consumer's className; the gradient replaces
* the text color, so color classes are ignored while shimmering.
*/
export function ShimmerText({ children, className }: ShimmerTextProps) {
return <span className={cn(styles.shimmer, className)}>{children}</span>
}
@@ -0,0 +1,366 @@
/**
* ThinkingLoader gooey animation
*
* Metaball technique on the SVG alpha channel: ink shapes are blurred with
* feGaussianBlur, then feColorMatrix crushes the soft alpha back to hard
* edges. Unlike the CSS blur+contrast trick this needs no solid backdrop and
* no blend mode, so the loader composites cleanly on any background.
*
* Every variant is authored in the shared 100x100 viewBox. All stages stack
* inside one filtered group; because the alpha crush happens after the
* stages composite, crossfading two stages reads as one pattern melting
* into the next, which is what drives the cycling morph.
*
* Ink color rides on currentColor: near-black on light surfaces, off-white
* in dark mode.
*/
.frame {
/* currentColor paints the squeeze ring's stroked arcs (the only non-filled
geometry). It tracks the loader polarity — dark on light, light on dark —
matching the gradient stops below. */
color: #2c2c2c;
flex: none;
/* Loader fill is a radial gradient (center → edge) plus a soft white inner
glow, per the Figma loader spec. Stops and glow opacity are theme vars,
inherited by the gradient stops and feFlood inside the SVG, so one set of
defs serves both themes. Light mode = the DARK-grey loader (reads on white):
#2C2C2C → #5F5F5F, glow white 0.6. */
--tl-grad-inner: #2c2c2c;
--tl-grad-outer: #5f5f5f;
--tl-glow: rgba(255, 255, 255, 0.6);
}
/* Gradient stops + glow flood read the theme vars via CSS (a raw `var()` in an
SVG presentation attribute would not resolve — it must come through CSS).
`stop-color`/`flood-color` are transitionable even when fed by a var, so a
caller that re-points the ink vars (e.g. settling the loader's material into
a surface it hands off to) gets a smooth color tween, not a hard flip. */
.gradInner {
stop-color: var(--tl-grad-inner);
transition: stop-color 450ms cubic-bezier(0.23, 1, 0.32, 1);
}
.gradOuter {
stop-color: var(--tl-grad-outer);
transition: stop-color 450ms cubic-bezier(0.23, 1, 0.32, 1);
}
.glow {
flood-color: var(--tl-glow);
transition: flood-color 450ms cubic-bezier(0.23, 1, 0.32, 1);
}
/* Wall-clock phase lock: the component sets --tl-sync to a shared negative
delay (now mod the common animation period), so every instance's keyframes
line up no matter when it mounted. The doubled class out-specifies the
shape classes' animation shorthand (implicit 0s delay) regardless of
source order. */
.frame.frame * {
animation-delay: var(--tl-sync, 0s);
}
:global(.dark) .frame {
color: #d6d6d6;
/* Dark mode = the LIGHT-grey loader (reads on dark): #A7A7A7 → #D6D6D6,
glow white 0.9. */
--tl-grad-inner: #a7a7a7;
--tl-grad-outer: #d6d6d6;
--tl-glow: rgba(255, 255, 255, 0.9);
}
/* An explicit `.light` wrapper (e.g. the always-light landing) re-asserts the
dark-grey loader even when an outer `.dark` theme is on the document — the
nearer theme wins. Ordered after the `.dark` rule so on equal specificity
(both ancestors present) this takes precedence. */
:global(.light) .frame {
color: #2c2c2c;
--tl-grad-inner: #2c2c2c;
--tl-grad-outer: #5f5f5f;
--tl-glow: rgba(255, 255, 255, 0.6);
}
.labelRow {
display: inline-flex;
align-items: center;
/* gap + label size scale with the loader (vars set per-instance); defaults
match the chat's size-20 loader (8px gap, 14px text). */
gap: var(--tl-label-gap, 8px);
/* The status phrase must never break onto a second line — the glyph + phrase
stay on one row regardless of phrase length or how narrow the available
width is (e.g. the hero loader docked near the card's right edge).
white-space inherits, so this one declaration reaches both the shimmer
(.label) and static (.labelStatic) text spans. */
white-space: nowrap;
}
/* The sweeping-band treatment itself (gradient, timing, dark mode, reduced
motion) is owned by the shared shimmer-text module; this class only adds the
loader-scaled font sizing. Canonical normal weight per emcn rules: body text
is 400, never medium. */
.label {
composes: shimmer from "./shimmer-text.module.css";
font-size: var(--tl-label-size, 14px);
font-weight: 400;
}
/* Static label (shimmer off): the phrase in solid body ink, no gradient sweep. */
.labelStatic {
font-size: var(--tl-label-size, 14px);
font-weight: 400;
color: var(--text-body);
}
/* Phrase crossfade — the incoming phrase rises and fades in while the outgoing
one rises and fades out (stacked over it), so phrases rotate smoothly. */
.labelStack {
position: relative;
display: inline-flex;
}
.labelLayer {
display: inline-flex;
}
/* Incoming phrase wipes in left → right through an animated gradient mask
(soft trailing edge), so it's revealed horizontally rather than fading up. */
.labelIn {
-webkit-mask-image: linear-gradient(90deg, #000 0, #000 70%, transparent 100%);
mask-image: linear-gradient(90deg, #000 0, #000 70%, transparent 100%);
-webkit-mask-size: 300% 100%;
mask-size: 300% 100%;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
animation: label-reveal 460ms ease both;
}
.labelOut {
position: absolute;
inset: 0;
animation: label-out 300ms ease both;
}
@keyframes label-reveal {
from {
opacity: 0;
-webkit-mask-position: 100% 0;
mask-position: 100% 0;
}
to {
opacity: 1;
-webkit-mask-position: 0% 0;
mask-position: 0% 0;
}
}
@keyframes label-out {
to {
opacity: 0;
}
}
.stage {
opacity: 0;
transition: opacity 0.5s ease;
}
.stageActive {
opacity: 1;
}
/* metaballs — two circles drift apart and melt back together */
.metaballsA {
animation: metaballs-a 1s infinite alternate;
}
.metaballsB {
animation: metaballs-b 1s infinite alternate;
}
@keyframes metaballs-a {
90%,
100% {
transform: translateX(28px);
}
}
@keyframes metaballs-b {
90%,
100% {
transform: translateX(-28px);
}
}
/* relay (Return) — always left → right: the ball emerges from the left post,
travels across, and dissolves into the right post (the goo morphs it into
that shape). It fades out at the right and in at the left, so the reset back
to the start happens while invisible — no bounce, no hard snap. */
.relayBall {
animation: relay 1.3s linear infinite;
}
@keyframes relay {
0% {
transform: translateX(0);
opacity: 0;
}
20% {
opacity: 1;
}
78% {
opacity: 1;
}
100% {
transform: translateX(58px);
opacity: 0;
}
}
/* corners — four dots rotate around a center square */
.cornersA {
animation: corners-a 0.8s infinite;
}
.cornersB {
animation: corners-b 0.8s infinite;
}
.cornersC {
animation: corners-c 0.8s infinite;
}
.cornersD {
animation: corners-d 0.8s infinite;
}
@keyframes corners-a {
100% {
transform: translate(46px, 0);
}
}
@keyframes corners-b {
100% {
transform: translate(0, 46px);
}
}
@keyframes corners-c {
100% {
transform: translate(-46px, 0);
}
}
@keyframes corners-d {
100% {
transform: translate(0, -46px);
}
}
/* burst — four dots repeatedly fling out of a center cross */
.burstUp {
animation: burst-up 0.8s infinite;
}
.burstDown {
animation: burst-down 0.8s infinite;
}
.burstLeft {
animation: burst-left 0.8s infinite;
}
.burstRight {
animation: burst-right 0.8s infinite;
}
@keyframes burst-up {
100% {
transform: translateY(-50px);
}
}
@keyframes burst-down {
100% {
transform: translateY(50px);
}
}
@keyframes burst-left {
100% {
transform: translateX(-50px);
}
}
@keyframes burst-right {
100% {
transform: translateX(50px);
}
}
/* compass — one dot tours the four cardinal points */
.compassMover {
animation: compass 2s infinite;
}
@keyframes compass {
0% {
transform: translate(0, 0);
}
25% {
transform: translate(27px, 27px);
}
50% {
transform: translate(0, 54px);
}
75% {
transform: translate(-27px, 27px);
}
100% {
transform: translate(0, 0);
}
}
/* squeeze — two bars pinch together inside the ring arcs */
.squeezeBarL {
animation: squeeze-l 0.6s infinite alternate;
}
.squeezeBarR {
animation: squeeze-r 0.6s infinite alternate;
}
@keyframes squeeze-l {
0%,
30% {
transform: translateX(0);
}
100% {
transform: translateX(10px);
}
}
@keyframes squeeze-r {
0%,
30% {
transform: translateX(0);
}
100% {
transform: translateX(-10px);
}
}
/* thinking — three small blobs drift out from the core and back on offset
periods (1.3/1.6/1.9s), so the merged cloud churns and never repeats:
the Core weighing options. */
.thinkA {
animation: think-a 1.6s infinite alternate;
}
.thinkB {
animation: think-b 1.9s infinite alternate;
}
.thinkC {
animation: think-c 1.3s infinite alternate;
}
@keyframes think-a {
100% {
transform: translate(-20px, -14px);
}
}
@keyframes think-b {
100% {
transform: translate(22px, -10px);
}
}
@keyframes think-c {
100% {
transform: translate(2px, 22px);
}
}
@media (prefers-reduced-motion: reduce) {
.frame *,
.labelIn {
animation: none;
}
.gradInner,
.gradOuter,
.glow {
transition: none;
}
/* No crossfade — the incoming phrase just replaces the outgoing one. */
.labelOut {
display: none;
}
}
+448
View File
@@ -0,0 +1,448 @@
'use client'
import { type CSSProperties, type ReactNode, useEffect, useId, useState } from 'react'
import { cn } from '@sim/emcn'
import styles from '@/components/ui/thinking-loader.module.css'
const VARIANTS = [
'metaballs',
'relay',
'corners',
'burst',
'compass',
'squeeze',
'thinking',
'orb',
] as const
export type ThinkingLoaderVariant = (typeof VARIANTS)[number]
/**
* Shapes used in the random morph cycle. `orb` (the solid terminal circle) is
* excluded — it is only reachable by pinning `variant='orb'` or via `settle`,
* so the cycle never lands on it mid-stream.
*/
const CYCLE_VARIANTS = VARIANTS.filter((v) => v !== 'orb')
/**
* Deterministic integer hash — turns a step index into a spread-out pseudo-
* random number. Used to pick both the shape and its hold time per step, so the
* order and pacing look unpredictable (any shape can follow any other, some
* linger, some pass quickly) while staying a pure function of the clock — every
* loader instance lands on the same shape at the same time.
*/
function hashStep(n: number): number {
let x = n | 0
x = Math.imul((x >>> 16) ^ x, 0x45d9f3b)
x = Math.imul((x >>> 16) ^ x, 0x45d9f3b)
return ((x >>> 16) ^ x) >>> 0
}
/**
* Common multiple of every shape animation period (800/1000/1200/2000ms,
* alternates doubled) — the wall-clock modulus for the shared negative
* animation-delay that phase-locks instances mounted at different times.
*/
const SYNC_PERIOD_MS = 12_000
/**
* A super-cycle of steps, each holding a pseudo-random shape for a pseudo-random
* duration (≈1.12.6s), so the morph pacing feels natural — some shapes linger,
* some pass quickly — instead of a metronome. Deterministic, so instances stay
* in lockstep; long enough not to read as a loop.
*/
const CYCLE_STEPS = 16
const STEP_MIN_MS = 1100
const STEP_RANGE_MS = 1500
const STEP_DURATIONS = Array.from(
{ length: CYCLE_STEPS },
(_, i) => STEP_MIN_MS + (hashStep(i + 1000) % STEP_RANGE_MS)
)
const SUPER_PERIOD_MS = STEP_DURATIONS.reduce((sum, d) => sum + d, 0)
/** The shape for a given step — pseudo-random, never an immediate repeat. */
function variantForStep(i: number): ThinkingLoaderVariant {
const idx = hashStep(i) % CYCLE_VARIANTS.length
const prevIdx = hashStep((i - 1 + CYCLE_STEPS) % CYCLE_STEPS) % CYCLE_VARIANTS.length
return CYCLE_VARIANTS[idx === prevIdx ? (idx + 1) % CYCLE_VARIANTS.length : idx]
}
/**
* The shape the shared timeline is on right now, and how long until the next.
* Walks the super-cycle's varied step durations — a pure function of the wall
* clock, so instances stay in lockstep.
*/
function variantAtNow(): { variant: ThinkingLoaderVariant; msUntilNext: number } {
let t = Date.now() % SUPER_PERIOD_MS
for (let i = 0; i < CYCLE_STEPS; i++) {
if (t < STEP_DURATIONS[i]) {
return { variant: variantForStep(i), msUntilNext: STEP_DURATIONS[i] - t }
}
t -= STEP_DURATIONS[i]
}
return { variant: variantForStep(0), msUntilNext: STEP_DURATIONS[0] }
}
/**
* Ink shapes per variant, authored in the shared 100x100 viewBox.
* Geometry mirrors the intrinsic CSS loaders these were adapted from,
* contain-fit to the canvas. Animations live in the CSS module.
*/
const VARIANT_SHAPES: Record<ThinkingLoaderVariant, ReactNode> = {
metaballs: (
<>
<circle className={styles.metaballsA} cx='22' cy='50' r='16' />
<circle className={styles.metaballsB} cx='78' cy='50' r='16' />
</>
),
relay: (
<>
<rect x='13' y='28' width='16' height='44' />
<rect x='71' y='28' width='16' height='44' />
<circle className={styles.relayBall} cx='21' cy='50' r='14' />
</>
),
corners: (
<>
<rect x='27' y='27' width='46' height='46' />
<circle className={styles.cornersA} cx='27' cy='27' r='14' />
<circle className={styles.cornersB} cx='73' cy='27' r='14' />
<circle className={styles.cornersC} cx='73' cy='73' r='14' />
<circle className={styles.cornersD} cx='27' cy='73' r='14' />
</>
),
// burst (Thinking) — four dots fling out of a center cross and are swallowed
// at the window edge, then fire again: the Core churning.
burst: (
<>
<rect x='12.5' y='43.75' width='75' height='12.5' />
<rect x='43.75' y='12.5' width='12.5' height='75' />
<circle cx='50' cy='50' r='12.5' />
<circle className={styles.burstUp} cx='50' cy='50' r='12.5' />
<circle className={styles.burstDown} cx='50' cy='50' r='12.5' />
<circle className={styles.burstLeft} cx='50' cy='50' r='12.5' />
<circle className={styles.burstRight} cx='50' cy='50' r='12.5' />
</>
),
compass: (
<>
<circle cx='50' cy='23' r='14' />
<circle cx='23' cy='50' r='14' />
<circle cx='77' cy='50' r='14' />
<circle cx='50' cy='77' r='14' />
<circle className={styles.compassMover} cx='50' cy='23' r='14' />
</>
),
squeeze: (
<>
{/* Arcs are stroked, not filled — they inherit the group's gradient stroke
(so the O is shaded identically to every other shape); the group pins
stroke-width to 0, so each arc re-asserts its own 12.5. */}
<path d='M 21.36 37.5 A 31.25 31.25 0 0 1 78.64 37.5' fill='none' strokeWidth='12.5' />
<path d='M 21.36 62.5 A 31.25 31.25 0 0 0 78.64 62.5' fill='none' strokeWidth='12.5' />
<rect className={styles.squeezeBarL} x='15' y='37.5' width='12.5' height='25' />
<rect className={styles.squeezeBarR} x='72.5' y='37.5' width='12.5' height='25' />
</>
),
// thinking — a core with small blobs drifting out and back on offset timings:
// a restless thought-cloud that never quite resolves. The Core deliberating.
thinking: (
<>
<circle cx='50' cy='50' r='15' />
<circle className={styles.thinkA} cx='50' cy='50' r='12' />
<circle className={styles.thinkB} cx='50' cy='50' r='12' />
<circle className={styles.thinkC} cx='50' cy='50' r='11' />
</>
),
// orb — a single solid disc that nearly fills the frame. Not part of the
// random cycle; the loader settles here so it can hand off to a real circular
// button (e.g. a send button) without a visible shape pop.
orb: <circle cx='50' cy='50' r='42' />,
}
/**
* World-aligned status phrase per shape (used when `phase` is set). Each phrase
* names what the shape represents in the Sim world, so the words on screen always
* match the loader. Keys are the shape names; the comment is the world concept.
*/
const VARIANT_PHRASE: Record<ThinkingLoaderVariant, string> = {
corners: 'Orchestrating…', // Mothership — the Core directing the work
squeeze: 'Working…', // Pod — one agent on a task
compass: 'In formation…', // Formation — many Pods in parallel
metaballs: 'Dispatching…', // Dispatch — sending work out
relay: 'Returning…', // Return — work coming back, consolidated
burst: 'Thinking…', // Thinking — the Core deliberating
thinking: 'Standing by…', // Waiting · Sentinel — holding for a condition
orb: 'Ready', // Orb — settled, ready to act
}
export interface ThinkingLoaderProps {
/** Pin one pattern. When omitted, the loader morphs between all patterns at random. */
variant?: ThinkingLoaderVariant
/**
* When cycling, open on this pattern (held one beat) before joining the
* shared morph timeline. Use `'corners'` (the Mothership shape) to always
* begin on the Core. Ignored when `variant` pins a single pattern.
*/
startVariant?: ThinkingLoaderVariant
/**
* Stop cycling and morph to the solid `orb` disc — the loader's terminal
* resting shape. The goo filter melts the current shape into the orb (no hard
* swap), so it can hand off to a real circular element underneath it. Ignored
* when `variant` pins a single pattern.
*/
settle?: boolean
/** Rendered square size in px. Defaults to 20. */
size?: number
/** Optional status text (e.g. "Thinking…") rendered beside the goo with a shimmer sweep. */
label?: string
/**
* Show a world-aligned status phrase that matches the current shape — e.g.
* "Dispatching…" under the Dispatch shape — updating as the loader morphs.
* Overrides `label`.
*/
phase?: boolean
/**
* Phrase/label font size as a fraction of `size`. Defaults to `0.7`. Lower it
* when the loader is shown scaled up (e.g. a zoomed hero shot) so the phrase
* doesn't read oversized next to the glyph.
*/
labelRatio?: number
/**
* Paint the label with the sweeping Claude-style shimmer (default). Set
* `false` for a static label in solid `--text-body` ink — no gradient, no
* sweep — while the phrase crossfade still applies.
*/
shimmer?: boolean
/** Layout-only classes (margins, alignment). The loader owns its chrome. */
className?: string
/**
* Escape hatch for the ink material: merged onto the SVG, so a caller can
* override the gradient/glow CSS vars (`--tl-grad-inner`, `--tl-grad-outer`,
* `--tl-glow`) to match a surface it hands off to. Inline, so it beats the
* module defaults. Use sparingly — the loader owns its look by default.
*/
style?: CSSProperties
}
/**
* Gooey mono thinking indicator shown wherever chat is working.
* Ink is near-black on light surfaces and off-white on dark surfaces.
*
* The goo filter operates on the alpha channel of transparent SVG shapes,
* so the loader needs no backdrop and composites on any background. All
* patterns share one filtered group, so the default cycling mode melts one
* pattern into the next instead of hard-swapping.
*
* @example
* ```tsx
* <ThinkingLoader label='Thinking…' />
* ```
*/
export function ThinkingLoader({
variant,
startVariant,
settle = false,
size = 20,
label,
phase,
labelRatio = 0.7,
shimmer = true,
className,
style,
}: ThinkingLoaderProps) {
// useId emits colons, which break url(#...) filter references — strip them.
const id = useId().replace(/[^a-zA-Z0-9-]/g, '')
const filterId = `tl-goo-${id}`
const clipId = `tl-clip-${id}`
const windowClipId = `tl-window-${id}`
const gradientId = `tl-grad-${id}`
const [cycleVariant, setCycleVariant] = useState<ThinkingLoaderVariant>(
startVariant ?? 'metaballs'
)
const cycling = variant === undefined
useEffect(() => {
if (!cycling) return
// Settle: stop the cycle and melt to the terminal orb (goo handles the morph).
if (settle) {
setCycleVariant('orb')
return
}
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
// When a startVariant is given, hold it for one min-step so the cycle always
// opens on that shape, then join the shared wall-clock morph timeline.
let opened = startVariant === undefined
let timeout: ReturnType<typeof setTimeout>
const tick = () => {
if (!opened) {
opened = true
setCycleVariant(startVariant as ThinkingLoaderVariant)
timeout = setTimeout(tick, STEP_MIN_MS)
return
}
const { variant: next, msUntilNext } = variantAtNow()
setCycleVariant(next)
timeout = setTimeout(tick, msUntilNext)
}
tick()
return () => clearTimeout(timeout)
}, [cycling, startVariant, settle])
// Phase-lock the CSS animations to the wall clock (set after mount so
// server and client markup agree). All instances share the same negative
// delay modulus, so their keyframes line up regardless of mount time.
const [syncDelay, setSyncDelay] = useState<string | undefined>(undefined)
useEffect(() => {
setSyncDelay(`-${Date.now() % SYNC_PERIOD_MS}ms`)
}, [])
const shown = variant ?? cycleVariant
// `phase` shows the world phrase for the shape on screen; otherwise the
// caller's static label (if any). Cycling, it updates as the shape morphs.
const displayLabel = phase ? VARIANT_PHRASE[shown] : label
// Crossfade the phrase when it changes: the outgoing phrase rises and fades
// out while the incoming one rises and fades in, so they rotate smoothly
// instead of snapping. The shimmer keeps running on the text underneath.
const [shownLabel, setShownLabel] = useState(displayLabel)
const [exitingLabel, setExitingLabel] = useState<string | undefined>(undefined)
useEffect(() => {
if (displayLabel === shownLabel) return
setExitingLabel(shownLabel)
setShownLabel(displayLabel)
}, [displayLabel, shownLabel])
useEffect(() => {
if (!exitingLabel) return
const timeout = setTimeout(() => setExitingLabel(undefined), 420)
return () => clearTimeout(timeout)
}, [exitingLabel])
const stages = cycling ? VARIANTS : [shown]
const loader = (
<svg
role={displayLabel ? undefined : 'status'}
aria-label={displayLabel ? undefined : 'Thinking'}
aria-hidden={displayLabel ? true : undefined}
viewBox='0 0 100 100'
width={size}
height={size}
className={cn(styles.frame, !displayLabel && className)}
style={{
...(syncDelay ? ({ '--tl-sync': syncDelay } as CSSProperties) : {}),
...style,
}}
>
<defs>
{/* sRGB so the radial gradient fill keeps its authored midtones
through the blur (linearRGB would wash the gradient out). The goo
crush runs first; the inner glow then rides the merged silhouette's
edge — a soft white inset, per the Figma loader spec. */}
<filter
id={filterId}
x='-30%'
y='-30%'
width='160%'
height='160%'
colorInterpolationFilters='sRGB'
>
<feGaussianBlur in='SourceGraphic' stdDeviation='5' result='blur' />
<feColorMatrix
in='blur'
values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 19 -9'
result='goo'
/>
{/* Inner shadow from the goo silhouette's alpha (Figma technique:
blur the alpha, subtract it from itself to leave an inner ring,
tint white). stdDeviation 4.86 = the spec's 3.5 scaled 72→100. */}
<feColorMatrix
in='goo'
type='matrix'
values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'
result='gooAlpha'
/>
<feGaussianBlur in='gooAlpha' stdDeviation='4.86' result='innerBlur' />
<feComposite
in='innerBlur'
in2='gooAlpha'
operator='arithmetic'
k2='-1'
k3='1'
result='innerMask'
/>
{/* Glow color + per-theme opacity ride a CSS var (light 0.6 / dark
0.9) so one filter serves both themes. */}
<feFlood className={styles.glow} result='glowColor' />
<feComposite in='glowColor' in2='innerMask' operator='in' result='glow' />
<feMerge>
<feMergeNode in='goo' />
<feMergeNode in='glow' />
</feMerge>
</filter>
{/* Radial gradient (center → edge), theme stops via CSS vars. Matches
the Figma loader: light #4F4F4F→#6F6F6F, dark #A7A7A7→#D6D6D6.
objectBoundingBox (default units) so EACH blob is shaded on its own
box — Figma fits the gradient per shape, so small/offset dots read
glossy (center dark, edge light) instead of flat. */}
<radialGradient id={gradientId} cx='0.5' cy='0.5' r='0.5'>
<stop className={styles.gradInner} />
<stop offset='1' className={styles.gradOuter} />
</radialGradient>
{/* Shapes clip BEFORE the goo filter, so anything exiting the frame
melts into the edge instead of getting a hard post-filter cut. */}
<clipPath id={clipId}>
<rect width='100' height='100' />
</clipPath>
{/* The original burst loader hid its flying dots under a 10px border,
swallowing them at the inner window — this clip reproduces it. */}
<clipPath id={windowClipId}>
<rect x='12.5' y='12.5' width='75' height='75' />
</clipPath>
</defs>
<g
filter={`url(#${filterId})`}
fill={`url(#${gradientId})`}
stroke={`url(#${gradientId})`}
strokeWidth={0}
>
{stages.map((v) => (
<g
key={v}
clipPath={`url(#${v === 'burst' ? windowClipId : clipId})`}
className={cn(styles.stage, v === shown && styles.stageActive)}
>
{VARIANT_SHAPES[v]}
</g>
))}
</g>
</svg>
)
if (!displayLabel) return loader
return (
<output
className={cn(styles.labelRow, className)}
style={
{
'--tl-label-size': `${size * labelRatio}px`,
'--tl-label-gap': `${size * 0.4}px`,
} as CSSProperties
}
>
{loader}
<span className={styles.labelStack}>
{exitingLabel ? (
<span key={exitingLabel} className={cn(styles.labelLayer, styles.labelOut)}>
<span className={shimmer ? styles.label : styles.labelStatic}>{exitingLabel}</span>
</span>
) : null}
<span key={shownLabel} className={cn(styles.labelLayer, styles.labelIn)}>
<span className={shimmer ? styles.label : styles.labelStatic}>{shownLabel}</span>
</span>
</span>
</output>
)
}