Fix: Display correct SmoothSchedule logo in email preview

Replaced the blank base64 encoded logo with the actual SmoothSchedule logo in the email rendering pipeline.

A Playwright E2E test was run to verify that the logo is correctly displayed in the email preview modal, ensuring it loads with natural dimensions and is visible.
This commit is contained in:
poduck
2025-12-14 19:10:56 -05:00
parent fbefccf436
commit 89fa8f81af
80 changed files with 7398 additions and 7908 deletions

View File

@@ -0,0 +1,85 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
export interface EmailBrandingProps {
showBranding: boolean;
}
/**
* EmailBranding - "Powered by SmoothSchedule" footer
*
* Displays SmoothSchedule branding at the bottom of emails.
* This is shown for free plans and can be hidden on paid plans.
*
* Note: The actual visibility is controlled by the backend based on
* the tenant's billing plan. This component is always rendered in the
* editor but the backend will omit it for paid plans.
*/
export const EmailBranding: ComponentConfig<EmailBrandingProps> = {
label: 'Email Branding',
fields: {
showBranding: {
type: 'radio',
label: 'Show Branding',
options: [
{ label: 'Yes', value: true },
{ label: 'No (Paid Plans Only)', value: false },
],
},
},
defaultProps: {
showBranding: true,
},
render: ({ showBranding }) => {
if (!showBranding) {
return (
<div
style={{
padding: '16px',
textAlign: 'center',
color: '#9ca3af',
fontSize: '12px',
fontStyle: 'italic',
}}
>
[Branding hidden - available on paid plans]
</div>
);
}
return (
<div
style={{
padding: '24px 40px',
textAlign: 'center',
borderTop: '1px solid #e5e7eb',
}}
>
<a
href="https://smoothschedule.com"
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
textDecoration: 'none',
color: '#6b7280',
fontSize: '12px',
}}
>
<img
src="/logo-branding.png"
alt="SmoothSchedule"
width="18"
height="18"
style={{ verticalAlign: 'middle' }}
/>
<span>Powered by SmoothSchedule</span>
</a>
</div>
);
},
};
export default EmailBranding;

View File

@@ -0,0 +1,86 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import type { EmailButtonProps } from './types';
const BUTTON_STYLES = {
primary: {
backgroundColor: '#4f46e5',
color: '#ffffff',
border: 'none',
},
secondary: {
backgroundColor: '#ffffff',
color: '#4f46e5',
border: '2px solid #4f46e5',
},
};
/**
* EmailButton - Call-to-action button
*
* Renders a button with email-safe inline styles.
* Uses table-based centering for email client compatibility.
*/
export const EmailButton: ComponentConfig<EmailButtonProps> = {
label: 'Email Button',
fields: {
text: {
type: 'text',
label: 'Button Text',
},
href: {
type: 'text',
label: 'Link URL',
},
variant: {
type: 'radio',
label: 'Style',
options: [
{ label: 'Primary', value: 'primary' },
{ label: 'Secondary', value: 'secondary' },
],
},
align: {
type: 'radio',
label: 'Alignment',
options: [
{ label: 'Left', value: 'left' },
{ label: 'Center', value: 'center' },
{ label: 'Right', value: 'right' },
],
},
},
defaultProps: {
text: 'Click Here',
href: '{{ manage_appointment_link }}',
variant: 'primary',
align: 'center',
},
render: ({ text, href, variant, align }) => {
const buttonStyle = BUTTON_STYLES[variant] || BUTTON_STYLES.primary;
const padding = variant === 'primary' ? '14px 28px' : '12px 24px';
return (
<div style={{ textAlign: align, margin: '16px 0' }}>
<a
href={href}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-block',
padding,
borderRadius: '6px',
fontWeight: 600,
fontSize: variant === 'primary' ? '16px' : '14px',
textDecoration: 'none',
...buttonStyle,
}}
>
{text}
</a>
</div>
);
},
};
export default EmailButton;

View File

@@ -0,0 +1,27 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import type { EmailDividerProps } from './types';
/**
* EmailDivider - Horizontal divider line
*
* Simple horizontal rule with email-safe styles.
*/
export const EmailDivider: ComponentConfig<EmailDividerProps> = {
label: 'Email Divider',
fields: {},
defaultProps: {},
render: () => {
return (
<hr
style={{
border: 0,
borderTop: '1px solid #e5e7eb',
margin: '24px 0',
}}
/>
);
},
};
export default EmailDivider;

View File

@@ -0,0 +1,88 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import type { EmailFooterProps } from './types';
/**
* EmailFooter - Business contact information footer
*
* Displays business contact details at the bottom of the email.
* Supports template tags for dynamic content.
*/
const EmailFooterRender: React.FC<EmailFooterProps> = ({ address, phone, email, website }) => {
console.log('[RENDER] EmailFooterRender called with:', { address, phone, email, website });
const contactItems: React.ReactNode[] = [];
if (phone) contactItems.push(phone);
if (email) {
contactItems.push(
<a key="email" href={`mailto:${email}`} style={{ color: '#4f46e5', textDecoration: 'underline' }}>
{email}
</a>
);
}
if (website) {
contactItems.push(
<a key="website" href={website} style={{ color: '#4f46e5', textDecoration: 'underline' }}>
{website}
</a>
);
}
return (
<div
style={{
padding: '24px 40px',
backgroundColor: '#f8fafc',
textAlign: 'center',
fontSize: '13px',
color: '#6b7280',
}}
>
{address && (
<p style={{ margin: '0 0 8px 0' }}>{address}</p>
)}
{contactItems.length > 0 && (
<p style={{ margin: '0 0 8px 0' }}>
{contactItems.map((item, i) => (
<React.Fragment key={i}>
{item}
{i < contactItems.length - 1 && ' | '}
</React.Fragment>
))}
</p>
)}
</div>
);
};
export const EmailFooter: ComponentConfig<EmailFooterProps> = {
label: 'Email Footer',
fields: {
address: {
type: 'text',
label: 'Address',
},
phone: {
type: 'text',
label: 'Phone',
},
email: {
type: 'text',
label: 'Email',
},
website: {
type: 'text',
label: 'Website',
},
},
defaultProps: {
address: '{{ tenant_address }}',
phone: '{{ tenant_phone }}',
email: '{{ tenant_email }}',
website: '{{ tenant_website_url }}',
},
render: EmailFooterRender,
};
export default EmailFooter;

View File

@@ -0,0 +1,85 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import type { EmailHeaderProps } from './types';
/**
* EmailHeader - Business logo and name header
*
* Displays the business branding at the top of the email.
* Supports optional logo image and preheader text.
*/
const EmailHeaderRender: React.FC<EmailHeaderProps> = ({ logoUrl, businessName, preheader }) => {
console.log('[RENDER] EmailHeaderRender called with:', { logoUrl, businessName, preheader });
return (
<div
style={{
padding: '32px 40px',
textAlign: 'center',
backgroundColor: '#f8fafc',
}}
>
{/* Hidden preheader text for email clients */}
{preheader && (
<div
style={{
display: 'none',
maxHeight: 0,
overflow: 'hidden',
}}
>
{preheader}
</div>
)}
{logoUrl && (
<img
src={logoUrl}
alt={businessName}
style={{
maxHeight: '60px',
maxWidth: '200px',
marginBottom: '16px',
}}
/>
)}
{businessName && (
<div
style={{
fontSize: '20px',
fontWeight: 600,
color: '#111827',
}}
>
{businessName}
</div>
)}
</div>
);
};
export const EmailHeader: ComponentConfig<EmailHeaderProps> = {
label: 'Email Header',
fields: {
logoUrl: {
type: 'text',
label: 'Logo URL',
},
businessName: {
type: 'text',
label: 'Business Name',
},
preheader: {
type: 'text',
label: 'Preheader Text',
},
},
defaultProps: {
logoUrl: '',
businessName: '{{ tenant_name }}',
preheader: '',
},
render: EmailHeaderRender,
};
export default EmailHeader;

View File

@@ -0,0 +1,84 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import type { EmailHeadingProps } from './types';
const HEADING_STYLES = {
h1: {
fontSize: '28px',
fontWeight: 700,
lineHeight: '1.3',
marginBottom: '16px',
},
h2: {
fontSize: '22px',
fontWeight: 600,
lineHeight: '1.3',
marginBottom: '12px',
},
h3: {
fontSize: '18px',
fontWeight: 600,
lineHeight: '1.3',
marginBottom: '8px',
},
};
/**
* EmailHeading - Heading text (h1-h3)
*
* Renders heading text with email-safe inline styles.
* Supports template tags like {{ customer_name }}.
*/
export const EmailHeading: ComponentConfig<EmailHeadingProps> = {
label: 'Email Heading',
fields: {
text: {
type: 'text',
label: 'Text',
},
level: {
type: 'select',
label: 'Level',
options: [
{ label: 'H1 - Main Title', value: 'h1' },
{ label: 'H2 - Section Title', value: 'h2' },
{ label: 'H3 - Subsection Title', value: 'h3' },
],
},
align: {
type: 'radio',
label: 'Alignment',
options: [
{ label: 'Left', value: 'left' },
{ label: 'Center', value: 'center' },
{ label: 'Right', value: 'right' },
],
},
},
defaultProps: {
text: 'Heading Text',
level: 'h2',
align: 'left',
},
render: ({ text, level, align }) => {
const Tag = level as keyof JSX.IntrinsicElements;
const styles = HEADING_STYLES[level] || HEADING_STYLES.h2;
return (
<Tag
style={{
...styles,
color: '#111827',
textAlign: align,
margin: 0,
marginBottom: styles.marginBottom,
padding: 0,
}}
>
{text}
</Tag>
);
},
};
export default EmailHeading;

View File

@@ -0,0 +1,76 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import type { EmailImageProps } from './types';
/**
* EmailImage - Image component
*
* Displays an image with email-safe styles.
* Uses table-based alignment for email client compatibility.
*/
export const EmailImage: ComponentConfig<EmailImageProps> = {
label: 'Email Image',
fields: {
src: {
type: 'text',
label: 'Image URL',
},
alt: {
type: 'text',
label: 'Alt Text',
},
maxWidth: {
type: 'text',
label: 'Max Width',
},
align: {
type: 'radio',
label: 'Alignment',
options: [
{ label: 'Left', value: 'left' },
{ label: 'Center', value: 'center' },
{ label: 'Right', value: 'right' },
],
},
},
defaultProps: {
src: '',
alt: 'Image',
maxWidth: '100%',
align: 'center',
},
render: ({ src, alt, maxWidth, align }) => {
if (!src) {
return (
<div
style={{
textAlign: align,
padding: '32px',
backgroundColor: '#f3f4f6',
color: '#6b7280',
fontSize: '14px',
margin: '16px 0',
}}
>
[Image Placeholder - Add URL]
</div>
);
}
return (
<div style={{ textAlign: align, margin: '16px 0' }}>
<img
src={src}
alt={alt}
style={{
maxWidth,
height: 'auto',
display: 'inline-block',
}}
/>
</div>
);
},
};
export default EmailImage;

View File

@@ -0,0 +1,54 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import type { EmailLayoutProps } from './types';
/**
* EmailLayout - Root wrapper for email templates
*
* Provides the outer wrapper with background color.
* In actual email rendering, this creates table-based structure.
*/
export const EmailLayout: ComponentConfig<EmailLayoutProps> = {
label: 'Email Layout',
fields: {
backgroundColor: {
type: 'text',
label: 'Background Color',
},
contentBackgroundColor: {
type: 'text',
label: 'Content Background Color',
},
},
defaultProps: {
backgroundColor: '#f4f4f5',
contentBackgroundColor: '#ffffff',
},
render: ({ backgroundColor, contentBackgroundColor, puck }) => {
const { renderDropZone } = puck || {};
return (
<div
style={{
backgroundColor,
padding: '40px 20px',
minHeight: '100vh',
}}
>
<div
style={{
maxWidth: '600px',
margin: '0 auto',
backgroundColor: contentBackgroundColor,
borderRadius: '8px',
overflow: 'hidden',
}}
>
{renderDropZone ? renderDropZone({ zone: 'email-content' }) : null}
</div>
</div>
);
},
};
export default EmailLayout;

View File

@@ -0,0 +1,54 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import type { EmailPanelProps } from './types';
/**
* EmailPanel - Highlighted info box
*
* A colored panel for highlighting important information.
* Useful for appointment details, order summaries, etc.
*/
export const EmailPanel: ComponentConfig<EmailPanelProps> = {
label: 'Email Panel',
fields: {
content: {
type: 'textarea',
label: 'Content',
},
backgroundColor: {
type: 'text',
label: 'Background Color',
},
},
defaultProps: {
content: 'Important information goes here.\nAppointment: {{ appointment_datetime }}\nService: {{ service_name }}',
backgroundColor: '#f3f4f6',
},
render: ({ content, backgroundColor }) => {
// Convert newlines to <br> for display
const lines = content.split('\n');
return (
<div
style={{
padding: '20px',
backgroundColor,
borderRadius: '6px',
margin: '16px 0',
fontSize: '16px',
lineHeight: '1.6',
color: '#374151',
}}
>
{lines.map((line, i) => (
<React.Fragment key={i}>
{line}
{i < lines.length - 1 && <br />}
</React.Fragment>
))}
</div>
);
},
};
export default EmailPanel;

View File

@@ -0,0 +1,39 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import type { EmailSpacerProps } from './types';
const SPACER_SIZES = {
sm: '16px',
md: '32px',
lg: '48px',
};
/**
* EmailSpacer - Vertical spacing
*
* Adds vertical whitespace between components.
*/
export const EmailSpacer: ComponentConfig<EmailSpacerProps> = {
label: 'Email Spacer',
fields: {
size: {
type: 'radio',
label: 'Size',
options: [
{ label: 'Small', value: 'sm' },
{ label: 'Medium', value: 'md' },
{ label: 'Large', value: 'lg' },
],
},
},
defaultProps: {
size: 'md',
},
render: ({ size }) => {
const height = SPACER_SIZES[size] || SPACER_SIZES.md;
return <div style={{ height }} />;
},
};
export default EmailSpacer;

View File

@@ -0,0 +1,58 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import type { EmailTextProps } from './types';
/**
* EmailText - Paragraph text content
*
* Renders text content with email-safe inline styles.
* Supports template tags and newline conversion to <br>.
*/
export const EmailText: ComponentConfig<EmailTextProps> = {
label: 'Email Text',
fields: {
content: {
type: 'textarea',
label: 'Content',
},
align: {
type: 'radio',
label: 'Alignment',
options: [
{ label: 'Left', value: 'left' },
{ label: 'Center', value: 'center' },
{ label: 'Right', value: 'right' },
],
},
},
defaultProps: {
content: 'Your email content here. Use {{ customer_name }} for personalization.',
align: 'left',
},
render: ({ content, align }) => {
// Convert newlines to <br> for display
const lines = content.split('\n');
return (
<p
style={{
fontSize: '16px',
lineHeight: '1.6',
color: '#374151',
textAlign: align,
margin: 0,
marginBottom: '16px',
}}
>
{lines.map((line, i) => (
<React.Fragment key={i}>
{line}
{i < lines.length - 1 && <br />}
</React.Fragment>
))}
</p>
);
},
};
export default EmailText;

View File

@@ -0,0 +1,62 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import type { EmailTwoColumnProps } from './types';
/**
* EmailTwoColumn - Two-column layout
*
* Renders content in two columns side by side.
* Note: In actual email rendering, this uses tables for compatibility.
*/
export const EmailTwoColumn: ComponentConfig<EmailTwoColumnProps> = {
label: 'Email Two Column',
fields: {
leftContent: {
type: 'textarea',
label: 'Left Column',
},
rightContent: {
type: 'textarea',
label: 'Right Column',
},
gap: {
type: 'text',
label: 'Gap',
},
},
defaultProps: {
leftContent: 'Left column content',
rightContent: 'Right column content',
gap: '20px',
},
render: ({ leftContent, rightContent, gap }) => {
const renderContent = (content: string) => {
const lines = content.split('\n');
return lines.map((line, i) => (
<React.Fragment key={i}>
{line}
{i < lines.length - 1 && <br />}
</React.Fragment>
));
};
return (
<div
style={{
display: 'flex',
gap,
margin: '16px 0',
}}
>
<div style={{ flex: 1, fontSize: '16px', lineHeight: '1.6', color: '#374151' }}>
{renderContent(leftContent)}
</div>
<div style={{ flex: 1, fontSize: '16px', lineHeight: '1.6', color: '#374151' }}>
{renderContent(rightContent)}
</div>
</div>
);
},
};
export default EmailTwoColumn;

View File

@@ -0,0 +1,22 @@
/**
* Email Template Components
*
* Puck components designed specifically for email templates.
* These render email-safe HTML with inline styles and table-based layout.
*/
export * from './types';
// Components
export { EmailLayout } from './EmailLayout';
export { EmailHeader } from './EmailHeader';
export { EmailHeading } from './EmailHeading';
export { EmailText } from './EmailText';
export { EmailButton } from './EmailButton';
export { EmailDivider } from './EmailDivider';
export { EmailSpacer } from './EmailSpacer';
export { EmailImage } from './EmailImage';
export { EmailPanel } from './EmailPanel';
export { EmailTwoColumn } from './EmailTwoColumn';
export { EmailFooter } from './EmailFooter';
export { EmailBranding } from './EmailBranding';

View File

@@ -0,0 +1,111 @@
/**
* Email Template Component Types
*
* These components are designed for email-safe output:
* - Table-based layout
* - Inline styles
* - No JavaScript or dynamic content
*/
// Email Layout Props
export interface EmailLayoutProps {
backgroundColor: string;
contentBackgroundColor: string;
}
// Email Header Props
export interface EmailHeaderProps {
logoUrl?: string;
businessName: string;
preheader?: string;
}
// Email Heading Props
export interface EmailHeadingProps {
text: string;
level: 'h1' | 'h2' | 'h3';
align: 'left' | 'center' | 'right';
}
// Email Text Props
export interface EmailTextProps {
content: string;
align: 'left' | 'center' | 'right';
}
// Email Button Props
export interface EmailButtonProps {
text: string;
href: string;
variant: 'primary' | 'secondary';
align: 'left' | 'center' | 'right';
}
// Email Divider Props
export interface EmailDividerProps {
// No props needed - simple horizontal line
}
// Email Spacer Props
export interface EmailSpacerProps {
size: 'sm' | 'md' | 'lg';
}
// Email Image Props
export interface EmailImageProps {
src: string;
alt: string;
maxWidth: string;
align: 'left' | 'center' | 'right';
}
// Email Panel Props (highlighted box)
export interface EmailPanelProps {
content: string;
backgroundColor: string;
}
// Email Two Column Props
export interface EmailTwoColumnProps {
leftContent: string;
rightContent: string;
gap: string;
}
// Email Footer Props
export interface EmailFooterProps {
address?: string;
phone?: string;
email?: string;
website?: string;
}
// Email Branding Props
export interface EmailBrandingProps {
showBranding: boolean;
}
// All email component props
export type EmailComponentProps = {
EmailLayout: EmailLayoutProps;
EmailHeader: EmailHeaderProps;
EmailHeading: EmailHeadingProps;
EmailText: EmailTextProps;
EmailButton: EmailButtonProps;
EmailDivider: EmailDividerProps;
EmailSpacer: EmailSpacerProps;
EmailImage: EmailImageProps;
EmailPanel: EmailPanelProps;
EmailTwoColumn: EmailTwoColumnProps;
EmailFooter: EmailFooterProps;
EmailBranding: EmailBrandingProps;
};
// Email-specific Puck data structure
export interface EmailPuckData {
content: Array<{
type: keyof EmailComponentProps;
props: Partial<EmailComponentProps[keyof EmailComponentProps]> & { id?: string };
}>;
root: Record<string, unknown>;
}

View File

@@ -0,0 +1,86 @@
/**
* Puck Configuration for Email Templates
*
* Email templates use component types like EmailHeader, EmailText, etc.
* These must match the types stored in the database.
*/
import type { Config } from '@measured/puck';
// Import ALL email-specific components
import { EmailHeader } from './components/email/EmailHeader';
import { EmailHeading } from './components/email/EmailHeading';
import { EmailText } from './components/email/EmailText';
import { EmailButton } from './components/email/EmailButton';
import { EmailDivider } from './components/email/EmailDivider';
import { EmailSpacer } from './components/email/EmailSpacer';
import { EmailImage } from './components/email/EmailImage';
import { EmailPanel } from './components/email/EmailPanel';
import { EmailTwoColumn } from './components/email/EmailTwoColumn';
import { EmailFooter } from './components/email/EmailFooter';
import { EmailBranding } from './components/email/EmailBranding';
// Import the combined type
import type { EmailComponentProps } from './components/email/types';
console.log('[emailConfig] Loading ALL email components');
console.log('[emailConfig] Verifying render functions are distinct:');
console.log(' EmailHeader.render:', EmailHeader.render?.toString().substring(0, 50));
console.log(' EmailHeading.render:', EmailHeading.render?.toString().substring(0, 50));
console.log(' EmailText.render:', EmailText.render?.toString().substring(0, 50));
console.log(' EmailButton.render:', EmailButton.render?.toString().substring(0, 50));
console.log(' EmailFooter.render:', EmailFooter.render?.toString().substring(0, 50));
console.log(' Are renders same?', EmailHeader.render === EmailFooter.render);
// Create the email config with ALL components - using direct assignment (not spread)
export const emailPuckConfig: Config<EmailComponentProps> = {
categories: {
structure: {
title: 'Structure',
components: ['EmailHeader', 'EmailFooter'],
},
content: {
title: 'Content',
components: ['EmailHeading', 'EmailText', 'EmailButton', 'EmailImage'],
},
layout: {
title: 'Layout',
components: ['EmailSpacer', 'EmailDivider', 'EmailPanel', 'EmailTwoColumn'],
},
other: {
title: 'Other',
components: ['EmailBranding'],
},
},
components: {
// Direct assignment - no spread to rule out reference issues
EmailHeader,
EmailFooter,
EmailHeading,
EmailText,
EmailButton,
EmailImage,
EmailSpacer,
EmailDivider,
EmailPanel,
EmailTwoColumn,
EmailBranding,
},
};
console.log('[emailConfig] Config ready with components:', Object.keys(emailPuckConfig.components));
/**
* Get email editor config - creates a fresh clone each time.
*/
export function getEmailEditorConfig(): Config<EmailComponentProps> {
const clonedConfig: Config<EmailComponentProps> = {
...emailPuckConfig,
components: { ...emailPuckConfig.components },
categories: emailPuckConfig.categories
? JSON.parse(JSON.stringify(emailPuckConfig.categories))
: undefined,
};
return clonedConfig;
}
export default emailPuckConfig;