Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 200 additions & 0 deletions apps/demo/emails/components/avatar-components.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import {
Avatar,
AvatarGroup,
Body,
Container,
Head,
Heading,
Hr,
Html,
Preview,
Section,
Text
} from 'jsx-email';

interface AvatarComponentsEmailProps {
adaAvatarSrc: string;
alanAvatarSrc: string;
graceAvatarSrc: string;
margaretAvatarSrc: string;
mikeAvatarSrc: string;
zorgAvatarSrc: string;
}

export const previewProps = {
adaAvatarSrc:
'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcR2lazm4zXHY4Oz9m_T7CBnhVPTukZCj9M18Q&s',
alanAvatarSrc:
'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcSE_-in-mMPGc3wHs2xuO4IdjZDG2UQje7enw&s',
graceAvatarSrc: 'https://i.pinimg.com/564x/63/8f/cc/638fcc11e752676aa3967274e643fa74.jpg',
margaretAvatarSrc:
'https://i.pinimg.com/474x/31/93/64/319364c3d37856f1fbfcf8bc4f88bc33.jpg',
mikeAvatarSrc: 'https://variety.com/wp-content/uploads/2017/02/mike-myers-1.jpg?w=700',
zorgAvatarSrc:
'https://images.fineartamerica.com/images/artworkimages/mediumlarge/3/zorg-the-fifth-element-joseph-oland.jpg'
} as AvatarComponentsEmailProps;

export const templateName = 'Avatar Components';

export const Template = ({
adaAvatarSrc,
alanAvatarSrc,
graceAvatarSrc,
margaretAvatarSrc,
mikeAvatarSrc,
zorgAvatarSrc
}: AvatarComponentsEmailProps = previewProps) => (
<Html>
<Head />
<Preview>Examples of avatar and avatar group components</Preview>
<Body style={main}>
<Container style={container}>
<Heading style={heading}>Avatar Component Examples</Heading>
<Text style={introText}>
This page demonstrates <code>{'<Avatar />'}</code> and <code>{'<AvatarGroup />'}</code>{' '}
usage patterns.
</Text>

<Section style={exampleSection}>
<Text style={exampleTitle}>Avatar</Text>
<Text style={exampleDescription}>Image avatar with explicit dimensions.</Text>
<Avatar src={adaAvatarSrc} name="Ada Lovelace" width={56} height={56} />
</Section>

<Section style={exampleSection}>
<Text style={exampleTitle}>Avatar (fallback initials)</Text>
<Text style={exampleDescription}>
Fallback initials from the `name` prop when no image is set.
</Text>
<Avatar name="Grace Hopper" width={56} height={56} />
</Section>

<Section style={exampleSection}>
<Text style={exampleTitle}>Avatar (custom fallback)</Text>
<Text style={exampleDescription}>Custom fallback token when no image is available.</Text>
<Avatar name="TBD User" fallback="TU" width={56} height={56} />
</Section>

<Section style={exampleSection}>
<Text style={exampleTitle}>Avatar (default / no picture examples)</Text>
<Text style={exampleDescription}>
Explicit no-picture avatars with initials and fallback text.
</Text>
<AvatarGroup spacing={10}>
<Avatar name="No Photo" width={44} height={44} />
<Avatar name="Guest User" fallback="GU" width={44} height={44} />
<Avatar fallback="?" width={44} height={44} />
</AvatarGroup>
</Section>

<Section style={exampleSection}>
<Text style={exampleTitle}>AvatarGroup</Text>
<Text style={exampleDescription}>Standard spacing for a small team roster.</Text>
<AvatarGroup>
<Avatar src={adaAvatarSrc} name="Ada Lovelace" width={44} height={44} />
<Avatar src={graceAvatarSrc} name="Grace Hopper" width={44} height={44} />
<Avatar src={alanAvatarSrc} name="Alan Turing" width={44} height={44} />
</AvatarGroup>
</Section>

<Section style={exampleSection}>
<Text style={exampleTitle}>AvatarGroup (overlap + max)</Text>
<Text style={exampleDescription}>
Overlap mode with overflow token for larger groups.
</Text>
<AvatarGroup overlap spacing={10} max={4}>
<Avatar src={adaAvatarSrc} name="Ada Lovelace" width={44} height={44} />
<Avatar src={graceAvatarSrc} name="Grace Hopper" width={44} height={44} />
<Avatar src={alanAvatarSrc} name="Alan Turing" width={44} height={44} />
<Avatar src={margaretAvatarSrc} name="Margaret Hamilton" width={44} height={44} />
<Avatar src={zorgAvatarSrc} name="Jean-Baptiste Zorg" width={44} height={44} />
<Avatar src={mikeAvatarSrc} name="Dr. Evil" width={44} height={44} />
</AvatarGroup>
</Section>

<Section style={exampleSection}>
<Text style={exampleTitle}>AvatarGroup (rtl)</Text>
<Text style={exampleDescription}>Right-to-left ordering for localized layouts.</Text>
<AvatarGroup direction="rtl" spacing={10}>
<Avatar src={zorgAvatarSrc} name="Jean-Baptiste Zorg" width={44} height={44} />
<Avatar src={margaretAvatarSrc} name="Margaret Hamilton" width={44} height={44} />
<Avatar src={mikeAvatarSrc} name="Dr. Evil" width={44} height={44} />
</AvatarGroup>
</Section>

<Hr style={divider} />
<Text style={footerText}>
Tip: open this template in the preview app sidebar under <strong>Components</strong>.
</Text>
</Container>
</Body>
</Html>
);

const main = {
backgroundColor: '#f8fafc',
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
margin: '0 auto'
};

const container = {
backgroundColor: '#ffffff',
border: '1px solid #e2e8f0',
borderRadius: '12px',
margin: '40px auto',
maxWidth: '640px',
padding: '28px'
};

const heading = {
color: '#0f172a',
fontSize: '26px',
lineHeight: '34px',
margin: 0,
marginBottom: '12px'
};

const introText = {
color: '#334155',
fontSize: '14px',
lineHeight: '22px',
margin: 0,
marginBottom: '24px'
};

const exampleSection = {
border: '1px solid #e2e8f0',
borderRadius: '8px',
marginBottom: '16px',
padding: '14px'
};

const exampleTitle = {
color: '#0f172a',
fontSize: '16px',
fontWeight: '600',
lineHeight: '22px',
margin: 0,
marginBottom: '6px'
};

const exampleDescription = {
color: '#475569',
fontSize: '13px',
lineHeight: '20px',
margin: 0,
marginBottom: '12px'
};

const divider = {
borderColor: '#e2e8f0',
margin: '18px 0'
};

const footerText = {
color: '#64748b',
fontSize: '12px',
lineHeight: '18px',
margin: 0
};
4 changes: 2 additions & 2 deletions apps/preview/app/src/components/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,14 +148,14 @@ const ColorSchemePicker = observer(() => {
return (
<RadixToggleGroup.Root
type="single"
className="inline-flex items-center gap-1 rounded-full border-2 border-neutral-200 dark:border-neutral-800 p-1"
className="inline-flex items-center gap-1 rounded-md border-2 border-neutral-200 dark:border-neutral-800 p-1"
value={appStore.colorScheme.preference}
>
{colorSchemes.map((colorScheme, index) => (
<RadixToggleGroup.Item
key={index}
value={colorScheme.name}
className="p-1 rounded-full data-[state=on]:outline hover:text-black dark:hover:text-white outline-2 outline-neutral-200 dark:outline-neutral-800 data-[state=off]:text-neutral-300 dark:data-[state=off]:text-neutral-700"
className="p-1 cursor-pointer data-[state=on]:rounded-sm data-[state=on]:outline hover:text-black dark:hover:text-white outline-neutral-200 dark:outline-neutral-800 data-[state=off]:text-neutral-300 dark:data-[state=off]:text-neutral-700"
onClick={() => appStore.colorScheme.setPreference(colorScheme.name)}
>
<Icon icon={colorScheme.icon} className="w-4 h-4" />
Expand Down
9 changes: 4 additions & 5 deletions apps/preview/app/src/components/ui/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import * as React from 'react';
import { cn } from '../../lib/utils';

const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full 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]:shrink-0',
'cursor-pointer 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]:shrink-0',
{
defaultVariants: {
size: 'default',
Expand All @@ -15,8 +15,8 @@ const buttonVariants = cva(
size: {
default: 'h-12 px-4 py-2 [&_svg]:size-5',
icon: 'h-11 w-11 [&_svg]:size-5',
lg: 'h-11 rounded-full px-8 [&_svg]:size-5',
sm: 'h-9 rounded-full px-3 [&_svg]:size-5'
lg: 'h-11 rounded-md px-8 [&_svg]:size-5',
sm: 'h-9 rounded-md px-3 [&_svg]:size-5'
},
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
Expand All @@ -31,8 +31,7 @@ const buttonVariants = cva(
);

export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}

Expand Down
4 changes: 2 additions & 2 deletions apps/preview/app/src/components/ui/toggle-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import * as RadixToggleGroup from '@radix-ui/react-toggle-group';
const Root = ({ children, ...props }: RadixToggleGroup.ToggleGroupSingleProps | undefined) => (
<RadixToggleGroup.Root
{...props}
className="flex items-center gap-2 p-2 rounded-full border-neutral-200 dark:border-neutral-800 bg-white dark:bg-black border-2"
className="flex items-center gap-2 p-1 rounded-sm border-neutral-200 dark:border-neutral-800 bg-white dark:bg-black border-2"
>
{children}
</RadixToggleGroup.Root>
Expand All @@ -14,7 +14,7 @@ const Root = ({ children, ...props }: RadixToggleGroup.ToggleGroupSingleProps |
const Item = ({ children, ...props }: RadixToggleGroup.ToggleGroupItemProps | undefined) => (
<RadixToggleGroup.Item
{...props}
className="p-2 rounded-full text-sm font-medium data-[state=on]:bg-black data-[state=on]:dark:bg-white data-[state=on]:text-white data-[state=on]:dark:text-black data-[state=on]:px-4 data-[state=off]:hover:underline"
className="cursor-pointer p-2 rounded-sm text-sm font-medium data-[state=on]:bg-black data-[state=on]:dark:bg-white data-[state=on]:text-white data-[state=on]:dark:text-black px-4 data-[state=off]:hover:bg-accent"
>
{children}
</RadixToggleGroup.Item>
Expand Down
37 changes: 28 additions & 9 deletions apps/preview/app/src/views/Preview/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import { Views } from '../../lib/types';
import { CodePreview } from './code-preview';
import { RenderPreview } from './render-preview';

const isView = (value: string | null): value is Views => {
return Object.values(Views).includes(value as Views);
};

export const Preview = observer(() => {
const appStore = useAppStore();

Expand All @@ -30,18 +34,33 @@ export const Preview = observer(() => {

const [searchParams, setSearchParams] = useSearchParams();

const currentView = searchParams.get('view') as Views;
const viewParam = searchParams.get('view');
const currentView = isView(viewParam) ? viewParam : Views.Desktop;

useEffect(() => {
if (!searchParams.get('view')) {
searchParams.set('view', Views.Desktop);
setSearchParams(searchParams);
const nextSearchParams = new URLSearchParams(searchParams);
let shouldUpdate = false;

if (!isView(viewParam)) {
nextSearchParams.set('view', Views.Desktop);
shouldUpdate = true;
}

if (shouldUpdate) {
setSearchParams(nextSearchParams);
}
}, [urlKey]);

function changeView(view: Views) {
searchParams.set('view', view);
setSearchParams(searchParams);
function setPreviewSearchParam(name: string, value: string) {
const nextSearchParams = new URLSearchParams(searchParams);
nextSearchParams.set(name, value);
setSearchParams(nextSearchParams);
}

function changeView(value: string) {
if (isView(value)) {
setPreviewSearchParam('view', value);
}
}

return (
Expand Down Expand Up @@ -73,9 +92,9 @@ export const Preview = observer(() => {
clients for Quality Control, before sending emails in production.
</PopoverContent>
</Popover>
<ToggleGroup.Root type="single" value={searchParams.get('view')}>
<ToggleGroup.Root type="single" value={currentView} onValueChange={changeView}>
{Object.entries(Views).map(([key, value], index) => (
<ToggleGroup.Item key={index} value={value} onClick={() => changeView(value)}>
<ToggleGroup.Item key={index} value={value}>
{key}
</ToggleGroup.Item>
))}
Expand Down
16 changes: 10 additions & 6 deletions apps/preview/app/src/views/Preview/render-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const PlunkLogo = ({ className }: { className?: string }) => (
</svg>
);

const SelectItem = ({ className, children, ...props }: RadixSelect.SelectItemProps) => (
const SelectItem = ({ className: _className, children, ...props }: RadixSelect.SelectItemProps) => (
<RadixSelect.Item
className={clsx(
'relative flex items-center px-4 py-2 rounded-md text-xs text-light-bg-text',
Expand All @@ -52,18 +52,22 @@ interface IframeStyle {
width?: `${number}px`;
}

export const RenderPreview = ({ mode, template }: HtmlRendererPreviewProps) => {
export const RenderPreview = ({
mode,
template
}: HtmlRendererPreviewProps) => {
const previewBaseStyles = /* html */ `
<style>
body {
overflow-wrap: anywhere;
}
</style>
`;
const srcDoc = template.html + previewBaseStyles;

const defaultDevice = devices.phones[3];

const iframeElRef = useRef<HTMLIFrameElement>();
const iframeElRef = useRef<HTMLIFrameElement | null>(null);

// const [activeDevice, setActiveDevice] = useState(() => devices.phones[0].name)
const [iframeStyle, setIframeStyle] = useState<IframeStyle>(
Expand Down Expand Up @@ -161,7 +165,7 @@ export const RenderPreview = ({ mode, template }: HtmlRendererPreviewProps) => {
</FlaotingToolbarPositioningController>
<iframe
ref={iframeElRef}
srcDoc={template.html + previewBaseStyles}
srcDoc={srcDoc}
className={clsx('w-full h-full', mode === Views.Device && 'mt-6 mb-24 mx-auto')}
style={mode === Views.Device ? iframeStyle : {}}
/>
Expand All @@ -173,7 +177,7 @@ export const RenderPreview = ({ mode, template }: HtmlRendererPreviewProps) => {
const [email, setEmail] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [sendState, setSendState] = useState<'idle' | 'sending' | 'error' | 'sent'>('idle');
const [sendError, setSendError] = useState<string>(null);
const [sendError, setSendError] = useState<string | null>(null);

async function handleSend(e: React.FormEvent) {
try {
Expand All @@ -199,7 +203,7 @@ export const RenderPreview = ({ mode, template }: HtmlRendererPreviewProps) => {
setSendError(error);
return;
}
} catch (error: unknown) {
} catch {
setSendError('Something went wrong. Please try again.');
} finally {
setSendState('error');
Expand Down
Loading
Loading