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
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:
@@ -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}</>
|
||||
}
|
||||
@@ -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
|
||||
@@ -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's been a few days since you signed up. I hope you're enjoying Sim!
|
||||
</Text>
|
||||
<Text style={styles.p}>
|
||||
I'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'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'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}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Social links row — hidden for whitelabeled instances */}
|
||||
{!isWhitelabeled && (
|
||||
<>
|
||||
<tr>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
</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}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style={baseStyles.spacer} height={16}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Address row — hidden for whitelabeled instances */}
|
||||
{!isWhitelabeled && (
|
||||
<>
|
||||
<tr>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
</td>
|
||||
<td style={baseStyles.footerText}>
|
||||
{brand.name}
|
||||
{isHosted && <>, 80 Langton St, San Francisco, CA 94103, USA</>}
|
||||
</td>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style={baseStyles.spacer} height={8}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Contact row */}
|
||||
<tr>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
</td>
|
||||
<td style={baseStyles.footerText}>
|
||||
Questions?{' '}
|
||||
<a href={`mailto:${brand.supportEmail}`} style={footerLinkStyle}>
|
||||
{brand.supportEmail}
|
||||
</a>
|
||||
</td>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style={baseStyles.spacer} height={8}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Message ID row (optional) */}
|
||||
{messageId && (
|
||||
<>
|
||||
<tr>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
</td>
|
||||
<td style={baseStyles.footerText}>
|
||||
Need to refer to this message? Use this ID: {messageId}
|
||||
</td>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style={baseStyles.spacer} height={8}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Links row */}
|
||||
<tr>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
</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}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Copyright row */}
|
||||
<tr>
|
||||
<td style={baseStyles.spacer} height={16}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
</td>
|
||||
<td style={baseStyles.footerText}>
|
||||
© {new Date().getFullYear()} {brand.name}, All Rights Reserved
|
||||
</td>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style={baseStyles.spacer} height={32}>
|
||||
|
||||
</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'
|
||||
@@ -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
|
||||
@@ -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,
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.1–2.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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user