feat(server): new email template (#9528)

use `yarn af server dev:mail` to preview all mail template
fix CLOUD-93
This commit is contained in:
darkskygit
2025-01-22 02:55:24 +00:00
parent 2db9cc3922
commit 83ed215f4a
54 changed files with 4794 additions and 892 deletions
@@ -0,0 +1,10 @@
import { UserProps } from './components';
import { WorkspaceProps } from './components/workspace';
export const TEST_USER: UserProps = {
email: 'test@test.com',
};
export const TEST_WORKSPACE: WorkspaceProps = {
name: 'Test Workspace',
};
@@ -0,0 +1,10 @@
import type { CSSProperties } from 'react';
export const BasicTextStyle: CSSProperties = {
fontSize: '15px',
fontWeight: '400',
lineHeight: '24px',
fontFamily: 'Inter, Arial, Helvetica, sans-serif',
margin: '24px 0 0',
color: '#141414',
};
@@ -0,0 +1,68 @@
import { Container, Img, Link, Row, Section } from '@react-email/components';
import type { CSSProperties } from 'react';
import { BasicTextStyle } from './common';
const TextStyles: CSSProperties = {
...BasicTextStyle,
color: '#8e8d91',
margin: 1,
marginTop: '8px',
};
export const Footer = () => {
return (
<Container
style={{
backgroundColor: '#fafafa',
maxWidth: '450px',
margin: '0 auto 32px auto',
borderRadius: '0 0 16px 16px',
boxShadow: '0px 0px 20px 0px rgba(66, 65, 73, 0.04)',
padding: '24px',
}}
>
<Section align="center" width="auto" style={{ margin: '1px auto' }}>
<Row>
{[
'Github',
'Twitter',
'Discord',
'Youtube',
'Telegram',
'Reddit',
].map(platform => (
<td key={platform} style={{ padding: '0 10px' }}>
<Link href={`https://affine.pro/${platform.toLowerCase()}`}>
<Img
src={`https://cdn.affine.pro/mail/2023-8-9/${platform}.png`}
alt={`affine ${platform.toLowerCase()} link`}
height="16px"
/>
</Link>
</td>
))}
</Row>
</Section>
<Section align="center" width="auto">
<Row style={TextStyles}>
<td>One hyper-fused platform for wildly creative minds</td>
</Row>
</Section>
<Section align="center" width="auto">
<Row style={TextStyles}>
<td>Copyright</td>
<td>
<Img
src="https://cdn.affine.pro/mail/2023-8-9/copyright.png"
alt="copyright"
height="14px"
style={{ verticalAlign: 'middle', margin: '0 4px' }}
/>
</td>
<td>2023-{new Date().getUTCFullYear()} ToEverything</td>
</Row>
</Section>
</Container>
);
};
@@ -0,0 +1,3 @@
export * from './template';
export * from './user';
export * from './workspace';
@@ -0,0 +1,207 @@
import {
Body,
Button as EmailButton,
Container,
Head,
Html,
Img,
Link,
Row,
Section,
Text as EmailText,
} from '@react-email/components';
import type { CSSProperties, PropsWithChildren } from 'react';
import { Footer } from './footer';
const BasicTextStyle: CSSProperties = {
fontSize: '15px',
fontWeight: '400',
lineHeight: '24px',
fontFamily: 'Inter, Arial, Helvetica, sans-serif',
margin: '24px 0 0',
color: '#141414',
};
export function Title(props: PropsWithChildren) {
return (
<EmailText
style={{
...BasicTextStyle,
fontSize: '20px',
fontWeight: '600',
lineHeight: '28px',
}}
>
{props.children}
</EmailText>
);
}
export function P(props: PropsWithChildren) {
return <EmailText style={BasicTextStyle}>{props.children}</EmailText>;
}
export function Text(props: PropsWithChildren) {
return <span style={BasicTextStyle}>{props.children}</span>;
}
export function Bold(props: PropsWithChildren) {
return <span style={{ fontWeight: 600 }}>{props.children}</span>;
}
export const Avatar = (props: {
img: string;
width?: string;
height?: string;
}) => {
return (
<img
src={props.img}
alt="avatar"
style={{
width: props.width || '20px',
height: props.height || '20px',
borderRadius: '12px',
objectFit: 'cover',
verticalAlign: 'middle',
}}
/>
);
};
export const Name = (props: PropsWithChildren) => {
return <Bold>{props.children}</Bold>;
};
export const AvatarWithName = (props: {
img?: string;
name: string;
width?: string;
height?: string;
}) => {
return (
<>
{props.img && (
<Avatar img={props.img} width={props.width} height={props.height} />
)}
<Name>{props.name}</Name>
</>
);
};
export function Content(props: PropsWithChildren) {
return typeof props.children === 'string' ? (
<EmailText>{props.children}</EmailText>
) : (
props.children
);
}
export function Button(
props: PropsWithChildren<{ type?: 'primary' | 'secondary'; href: string }>
) {
const style = {
...BasicTextStyle,
backgroundColor: props.type === 'secondary' ? '#FFFFFF' : '#1E96EB',
color: props.type === 'secondary' ? '#141414' : '#FFFFFF',
textDecoration: 'none',
fontWeight: '600',
padding: '8px 18px',
borderRadius: '8px',
border: '1px solid rgba(0,0,0,.1)',
marginRight: '4px',
};
return (
<EmailButton style={style} href={props.href}>
{props.children}
</EmailButton>
);
}
function fetchTitle(
children: React.ReactElement<PropsWithChildren>[]
): React.ReactElement {
const title = children.find(child => child.type === Title);
if (!title || !title.props.children) {
throw new Error('<Title /> is required for an email.');
}
return title;
}
function fetchContent(
children: React.ReactElement<PropsWithChildren>[]
): React.ReactElement | React.ReactElement[] {
const content = children.find(child => child.type === Content);
if (!content || !content.props.children) {
throw new Error('<Content /> is required for an email.');
}
if (Array.isArray(content.props.children)) {
return content.props.children.map((child, i) => {
/* oxlint-disable-next-line eslint-plugin-react/no-array-index-key */
return <Row key={i}>{child}</Row>;
});
}
return content;
}
function assertChildrenIsArray(
children: React.ReactNode
): asserts children is React.ReactElement<PropsWithChildren>[] {
if (!Array.isArray(children) || !children.every(child => 'type' in child)) {
throw new Error(
'Children of `Template` element must be an array of [<Title />, <Content />, ...]'
);
}
}
export function Template(props: PropsWithChildren) {
assertChildrenIsArray(props.children);
const content = (
<>
<Section>{fetchTitle(props.children)}</Section>
<Section>{fetchContent(props.children)}</Section>
</>
);
if (typeof AFFiNE !== 'undefined' && AFFiNE.node.test) {
return content;
}
return (
<Html>
<Head />
<Body style={{ backgroundColor: '#f6f7fb', overflow: 'hidden' }}>
<Container
style={{
backgroundColor: '#fff',
maxWidth: '450px',
margin: '32px auto 0',
borderRadius: '16px 16px 0 0',
boxShadow: '0px 0px 20px 0px rgba(66, 65, 73, 0.04)',
padding: '24px',
}}
>
<Section>
<Link href="https://affine.pro">
<Img
src="https://cdn.affine.pro/mail/2023-8-9/affine-logo.png"
alt="AFFiNE logo"
height="32px"
/>
</Link>
</Section>
{content}
</Container>
<Footer />
</Body>
</Html>
);
}
@@ -0,0 +1,9 @@
import { Name } from './template';
export interface UserProps {
email: string;
}
export const User = (props: UserProps) => {
return <Name>{props.email}</Name>;
};
@@ -0,0 +1,18 @@
import { AvatarWithName } from './template';
export interface WorkspaceProps {
name: string;
avatar?: string;
size?: number;
}
export const Workspace = (props: WorkspaceProps) => {
return (
<AvatarWithName
name={props.name}
img={props.avatar}
width={`${props.size}px`}
height={`${props.size}px`}
/>
);
};
+177
View File
@@ -0,0 +1,177 @@
import { render as rawRender } from '@react-email/render';
import {
TeamBecomeAdmin,
TeamBecomeCollaborator,
TeamDeleteIn24Hours,
TeamDeleteInOneMonth,
TeamExpired,
TeamExpireSoon,
TeamWorkspaceDeleted,
TeamWorkspaceUpgraded,
} from './teams';
import {
ChangeEmail,
ChangeEmailNotification,
ChangePassword,
SetPassword,
SignIn,
SignUp,
VerifyChangeEmail,
VerifyEmail,
} from './users';
import {
Invitation,
InvitationAccepted,
LinkInvitationApproved,
LinkInvitationReviewDeclined,
LinkInvitationReviewRequest,
MemberLeave,
MemberRemoved,
OwnershipReceived,
OwnershipTransferred,
} from './workspaces';
type EmailContent = {
subject: string;
html: string;
};
function render(component: React.ReactElement) {
return rawRender(component, {
pretty: AFFiNE.node.test,
});
}
type Props<T> = T extends React.FC<infer P> ? P : never;
export type EmailRenderer<Props> = (props: Props) => Promise<EmailContent>;
function make<T extends React.ComponentType<any>>(
Component: T,
subject: string | ((props: Props<T>) => string)
): EmailRenderer<Props<T>> {
return async props => {
if (!props && AFFiNE.node.test) {
// @ts-expect-error test only
props = Component.PreviewProps;
}
return {
subject: typeof subject === 'function' ? subject(props) : subject,
html: await render(<Component {...props} />),
};
};
}
// ================ User ================
export const renderSignInMail = make(SignIn, 'Sign in to AFFiNE');
export const renderSignUpMail = make(
SignUp,
'Your AFFiNE account is waiting for you!'
);
export const renderSetPasswordMail = make(
SetPassword,
'Set your AFFiNE password'
);
export const renderChangePasswordMail = make(
ChangePassword,
'Modify your AFFiNE password'
);
export const renderVerifyEmailMail = make(
VerifyEmail,
'Verify your email address'
);
export const renderChangeEmailMail = make(
ChangeEmail,
'Change your email address'
);
export const renderVerifyChangeEmailMail = make(
VerifyChangeEmail,
'Verify your new email address'
);
export const renderChangeEmailNotificationMail = make(
ChangeEmailNotification,
'Account email address changed'
);
// ================ Workspace ================
export const renderMemberInvitationMail = make(
Invitation,
props => `${props.user.email} invited you to join ${props.workspace.name}`
);
export const renderMemberAcceptedMail = make(
InvitationAccepted,
props => `${props.user.email} accepted your invitation`
);
export const renderMemberLeaveMail = make(
MemberLeave,
props => `${props.user.email} left ${props.workspace.name}`
);
export const renderLinkInvitationReviewRequestMail = make(
LinkInvitationReviewRequest,
props => `New request to join ${props.workspace.name}`
);
export const renderLinkInvitationApproveMail = make(
LinkInvitationApproved,
props => `Your request to join ${props.workspace.name} has been approved`
);
export const renderLinkInvitationDeclineMail = make(
LinkInvitationReviewDeclined,
props => `Your request to join ${props.workspace.name} was declined`
);
export const renderMemberRemovedMail = make(
MemberRemoved,
props => `You have been removed from ${props.workspace.name}`
);
export const renderOwnershipTransferredMail = make(
OwnershipTransferred,
props => `Your ownership of ${props.workspace.name} has been transferred`
);
export const renderOwnershipReceivedMail = make(
OwnershipReceived,
props => `You are now the owner of ${props.workspace.name}`
);
// ================ Team ================
export const renderTeamWorkspaceUpgradedMail = make(
TeamWorkspaceUpgraded,
props =>
props.isOwner
? 'Your workspace has been upgraded to team workspace! 🎉'
: `${props.workspace.name} has been upgraded to team workspace! 🎉`
);
export const renderTeamBecomeAdminMail = make(
TeamBecomeAdmin,
props => `You are now an admin of ${props.workspace.name}`
);
export const renderTeamBecomeCollaboratorMail = make(
TeamBecomeCollaborator,
props => `Your role has been changed in ${props.workspace.name}`
);
export const renderTeamDeleteIn24HoursMail = make(
TeamDeleteIn24Hours,
props =>
`[Action Required] Final warning: Your workspace ${props.workspace.name} will be deleted in 24 hours`
);
export const renderTeamDeleteIn1MonthMail = make(
TeamDeleteInOneMonth,
props =>
`[Action Required] Important: Your workspace ${props.workspace.name} will be deleted soon`
);
export const renderTeamWorkspaceDeletedMail = make(
TeamWorkspaceDeleted,
props => `Your workspace ${props.workspace.name} has been deleted`
);
export const renderTeamWorkspaceExpireSoonMail = make(
TeamExpireSoon,
props =>
`[Action Required] Your ${props.workspace.name} team workspace will expire soon`
);
export const renderTeamWorkspaceExpiredMail = make(
TeamExpired,
props => `Your ${props.workspace.name} team workspace has expired`
);
@@ -0,0 +1,38 @@
import { TEST_WORKSPACE } from '../common';
import {
Button,
Content,
P,
Template,
Title,
Workspace,
type WorkspaceProps,
} from '../components';
export type TeamBecomeAdminProps = {
workspace: WorkspaceProps;
url: string;
};
export default function TeamBecomeAdmin(props: TeamBecomeAdminProps) {
const { workspace, url } = props;
return (
<Template>
<Title>You&apos;ve been promoted to admin.</Title>
<Content>
<P>
You have been promoted to admin of <Workspace {...workspace} />. As an
admin, you can help the workspace owner manage members in this
workspace.
</P>
<Button href={url}>Go to Workspace</Button>
</Content>
</Template>
);
}
TeamBecomeAdmin.PreviewProps = {
workspace: TEST_WORKSPACE,
role: 'admin',
url: 'https://app.affine.pro',
};
@@ -0,0 +1,40 @@
import { TEST_WORKSPACE } from '../common';
import {
Button,
Content,
P,
Template,
Title,
Workspace,
type WorkspaceProps,
} from '../components';
export type TeamBecomeCollaboratorProps = {
workspace: WorkspaceProps;
url: string;
};
export default function TeamBecomeCollaborator(
props: TeamBecomeCollaboratorProps
) {
const { workspace, url } = props;
return (
<Template>
<Title>Role update in workspace</Title>
<Content>
<P>
Your role in <Workspace {...workspace} /> has been changed to{' '}
collaborator. You can continue to collaborate in this workspace.
</P>
<Button href={url}>Go to Workspace</Button>
</Content>
</Template>
);
}
TeamBecomeCollaborator.PreviewProps = {
workspace: TEST_WORKSPACE,
role: 'admin',
url: 'https://app.affine.pro',
};
@@ -0,0 +1,55 @@
import { TEST_WORKSPACE } from '../common';
import {
Bold,
Button,
Content,
P,
Template,
Text,
Title,
Workspace,
type WorkspaceProps,
} from '../components';
export interface TeamDeleteInOneMonthProps {
workspace: WorkspaceProps;
expirationDate: Date;
deletionDate: Date;
url: string;
}
export default function TeamDeleteInOneMonth(props: TeamDeleteInOneMonthProps) {
const { workspace, expirationDate, deletionDate, url } = props;
return (
<Template>
<Title>Take action to prevent data loss</Title>
<Content>
<P>
Your <Workspace {...workspace} /> team workspace expired on{' '}
<Bold>{expirationDate.toLocaleDateString()}</Bold>. All workspace data
will be permanently deleted on{' '}
<Bold>{deletionDate.toLocaleDateString()}</Bold> (180 days after
expiration). To prevent data loss, please either:
<li>
<Text>Renew your subscription to restore team features</Text>
</li>
<li>
<Text>
Export your workspace data from Workspace Settings &gt; Export
Workspace
</Text>
</li>
</P>
<Button href={url}>Go to Billing</Button>
</Content>
</Template>
);
}
TeamDeleteInOneMonth.PreviewProps = {
workspace: TEST_WORKSPACE,
expirationDate: new Date('2025-01-01T00:00:00Z'),
deletionDate: new Date('2025-01-31T00:00:00Z'),
url: 'https://app.affine.pro',
};
@@ -0,0 +1,52 @@
import { TEST_WORKSPACE } from '../common';
import {
Bold,
Button,
Content,
P,
Template,
Text,
Title,
Workspace,
type WorkspaceProps,
} from '../components';
export interface TeamDeleteIn24HoursProps {
workspace: WorkspaceProps;
deletionDate: Date;
url: string;
}
export default function TeamDeleteIn24Hours(props: TeamDeleteIn24HoursProps) {
const { workspace, deletionDate, url } = props;
return (
<Template>
<Title>Urgent: Last chance to prevent data loss</Title>
<Content>
<P>
Your <Workspace {...workspace} /> team workspace data will be
permanently deleted in 24 hours on{' '}
<Bold>{deletionDate.toLocaleDateString()}</Bold>. To prevent data
loss, please take immediate action:
<li>
<Text>Renew your subscription to restore team features</Text>
</li>
<li>
<Text>
Export your workspace data from Workspace Settings &gt; Export
Workspace
</Text>
</li>
</P>
<Button href={url}>Go to Billing</Button>
</Content>
</Template>
);
}
TeamDeleteIn24Hours.PreviewProps = {
workspace: TEST_WORKSPACE,
deletionDate: new Date('2025-01-31T00:00:00Z'),
url: 'https://app.affine.pro',
};
@@ -0,0 +1,38 @@
import { TEST_WORKSPACE } from '../common';
import {
Content,
P,
Template,
Title,
Workspace,
type WorkspaceProps,
} from '../components';
export interface TeamWorkspaceDeletedProps {
workspace: WorkspaceProps;
}
export default function TeamWorkspaceDeleted(props: TeamWorkspaceDeletedProps) {
const { workspace } = props;
return (
<Template>
<Title>Workspace data deleted</Title>
<Content>
<P>
All data in <Workspace {...workspace} /> has been permanently deleted
as the workspace remained expired for 180 days. This action cannot be
undone.
</P>
<P>
Thank you for your support of AFFiNE. We hope to see you again in the
future.
</P>
</Content>
</Template>
);
}
TeamWorkspaceDeleted.PreviewProps = {
workspace: TEST_WORKSPACE,
};
@@ -0,0 +1,42 @@
import { TEST_WORKSPACE } from '../common';
import {
Bold,
Button,
Content,
P,
Template,
Title,
Workspace,
type WorkspaceProps,
} from '../components';
export interface TeamExpireSoonProps {
workspace: WorkspaceProps;
expirationDate: Date;
url: string;
}
export default function TeamExpireSoon(props: TeamExpireSoonProps) {
const { workspace, expirationDate, url } = props;
return (
<Template>
<Title>Team workspace will expire soon</Title>
<Content>
<P>
Your <Workspace {...workspace} /> team workspace will expire on{' '}
<Bold>{expirationDate.toLocaleDateString()}</Bold>. After expiration,
you won&apos;t be able to sync or collaborate with team members.
Please renew your subscription to continue using all team features.
</P>
<Button href={url}>Go to Billing</Button>
</Content>
</Template>
);
}
TeamExpireSoon.PreviewProps = {
workspace: TEST_WORKSPACE,
expirationDate: new Date('2025-01-01T00:00:00Z'),
url: 'https://app.affine.pro',
};
@@ -0,0 +1,42 @@
import { TEST_WORKSPACE } from '../common';
import {
Bold,
Button,
Content,
P,
Template,
Title,
Workspace,
type WorkspaceProps,
} from '../components';
export interface TeamExpiredProps {
workspace: WorkspaceProps;
expirationDate: Date;
url: string;
}
export default function TeamExpired(props: TeamExpiredProps) {
const { workspace, expirationDate, url } = props;
return (
<Template>
<Title>Team workspace expired</Title>
<Content>
<P>
Your <Workspace {...workspace} /> team workspace expired on{' '}
<Bold>{expirationDate.toLocaleDateString()}</Bold>. Your workspace
can&apos;t sync or collaborate with team members. Please renew your
subscription to restore all team features.
</P>
<Button href={url}>Go to Billing</Button>
</Content>
</Template>
);
}
TeamExpired.PreviewProps = {
workspace: TEST_WORKSPACE,
expirationDate: new Date('2025-01-01T00:00:00Z'),
url: 'https://app.affine.pro',
};
@@ -0,0 +1,29 @@
export {
default as TeamBecomeAdmin,
type TeamBecomeAdminProps,
} from './become-admin';
export {
default as TeamBecomeCollaborator,
type TeamBecomeCollaboratorProps,
} from './become-collaborator';
export {
default as TeamDeleteInOneMonth,
type TeamDeleteInOneMonthProps,
} from './delete-in-1m';
export {
default as TeamDeleteIn24Hours,
type TeamDeleteIn24HoursProps,
} from './delete-in-24h';
export {
default as TeamWorkspaceDeleted,
type TeamWorkspaceDeletedProps,
} from './deleted';
export {
default as TeamExpireSoon,
type TeamExpireSoonProps,
} from './expire-soon';
export { default as TeamExpired, type TeamExpiredProps } from './expired';
export {
default as TeamWorkspaceUpgraded,
type TeamWorkspaceUpgradedProps,
} from './workspace-upgraded';
@@ -0,0 +1,57 @@
import { TEST_WORKSPACE } from '../common';
import {
Button,
Content,
P,
Template,
Title,
Workspace,
type WorkspaceProps,
} from '../components';
export type TeamWorkspaceUpgradedProps = {
workspace: WorkspaceProps;
isOwner: boolean;
url: string;
};
export default function TeamWorkspaceUpgraded(
props: TeamWorkspaceUpgradedProps
) {
const { workspace, isOwner, url } = props;
return (
<Template>
<Title>Welcome to the team workspace!</Title>
<Content>
<P>
{isOwner ? (
<>
<Workspace {...workspace} /> has been upgraded to team workspace
with the following benefits:
</>
) : (
<>
Great news! <Workspace {...workspace} /> has been upgraded to team
workspace by the workspace owner.
<br />
You now have access to the following enhanced features:
</>
)}
<br /> 100 GB initial storage + 20 GB per seat
<br /> 500 MB of maximum file size
<br /> Unlimited team members (10+ seats)
<br /> Multiple admin roles
<br /> Priority customer support
</P>
<Button href={url}>Open Workspace</Button>
</Content>
</Template>
);
}
TeamWorkspaceUpgraded.PreviewProps = {
workspace: TEST_WORKSPACE,
isOwner: true,
url: 'https://app.affine.pro',
};
@@ -0,0 +1,25 @@
import { Content, Name, P, Template, Title } from '../components';
export type ChangeEmailNotificationProps = {
to: string;
};
export default function ChangeEmailNotification(
props: ChangeEmailNotificationProps
) {
return (
<Template>
<Title>Verify your current email for AFFiNE</Title>
<Content>
<P>
As per your request, we have changed your email. Please make sure
you&apos;re using <Name>{props.to}</Name> to log in the next time.
</P>
</Content>
</Template>
);
}
ChangeEmailNotification.PreviewProps = {
to: 'test@affine.pro',
};
@@ -0,0 +1,25 @@
import { Button, Content, P, Template, Title } from '../components';
export type VerifyChangeEmailProps = {
url: string;
};
export default function VerifyChangeEmail(props: VerifyChangeEmailProps) {
return (
<Template>
<Title>Verify your new email address</Title>
<Content>
<P>
You recently requested to change the email address associated with
your AFFiNE account. To complete this process, please click on the
verification link below. This magic link will expire in 30 minutes.
</P>
<Button href={props.url}>Verify your new email address</Button>
</Content>
</Template>
);
}
VerifyChangeEmail.PreviewProps = {
url: 'https://app.affine.pro',
};
@@ -0,0 +1,29 @@
import { Bold, Button, Content, P, Template, Title } from '../components';
export type ChangeEmailProps = {
url: string;
};
export default function ChangeEmail(props: ChangeEmailProps) {
return (
<Template>
<Title>Verify your current email for AFFiNE</Title>
<Content>
<P>
You recently requested to change the email address associated with
your AFFiNE account.
<br />
To complete this process, please click on the verification link below.
</P>
<P>
This magic link will expire in <Bold>30 minutes</Bold>.
</P>
<Button href={props.url}>Verify and set up a new email address</Button>
</Content>
</Template>
);
}
ChangeEmail.PreviewProps = {
url: 'https://app.affine.pro',
};
@@ -0,0 +1,29 @@
import { Bold, Button, Content, P, Template, Title } from '../components';
export type VerifyEmailProps = {
url: string;
};
export default function VerifyEmail(props: VerifyEmailProps) {
return (
<Template>
<Title>Verify your email address</Title>
<Content>
<P>
You recently requested to verify the email address associated with
your AFFiNE account.
<br />
To complete this process, please click on the verification link below.
</P>
<P>
This magic link will expire in <Bold>30 minutes</Bold>.
</P>
<Button href={props.url}>Verify your email address</Button>
</Content>
</Template>
);
}
VerifyEmail.PreviewProps = {
url: 'https://app.affine.pro',
};
@@ -0,0 +1,17 @@
export { default as ChangeEmail, type ChangeEmailProps } from './email-change';
export {
default as ChangeEmailNotification,
type ChangeEmailNotificationProps,
} from './email-change-notification';
export {
default as VerifyChangeEmail,
type VerifyChangeEmailProps,
} from './email-change-verify';
export { default as VerifyEmail, type VerifyEmailProps } from './email-verify';
export {
default as ChangePassword,
type ChangePasswordProps,
} from './password-change';
export { default as SetPassword, type SetPasswordProps } from './password-set';
export { default as SignIn, type SignInProps } from './sign-in';
export { default as SignUp, type SignUpProps } from './sign-up';
@@ -0,0 +1,24 @@
import { Bold, Button, Content, P, Template, Title } from '../components';
export type ChangePasswordProps = {
url: string;
};
export default function ChangePassword(props: ChangePasswordProps) {
return (
<Template>
<Title>Modify your AFFiNE password</Title>
<Content>
<P>
Click the button below to reset your password. The magic link will
expire in <Bold>30 minutes</Bold>.
</P>
<Button href={props.url}>Set new password</Button>
</Content>
</Template>
);
}
ChangePassword.PreviewProps = {
url: 'https://app.affine.pro',
};
@@ -0,0 +1,24 @@
import { Bold, Button, Content, P, Template, Title } from '../components';
export type SetPasswordProps = {
url: string;
};
export default function SetPassword(props: SetPasswordProps) {
return (
<Template>
<Title>Set your AFFiNE password</Title>
<Content>
<P>
Click the button below to set your password. The magic link will
expire in <Bold>30 minutes</Bold>.
</P>
<Button href={props.url}>Sign in to AFFiNE</Button>
</Content>
</Template>
);
}
SetPassword.PreviewProps = {
url: 'https://app.affine.pro',
};
@@ -0,0 +1,20 @@
import { Button, Content, P, Template, Title } from '../components';
export type SignInProps = {
url: string;
};
export default function SignUp(props: SignInProps) {
return (
<Template>
<Title>Sign in to AFFiNE</Title>
<Content>
<P>
Click the button below to securely sign in. The magic link will expire
in 30 minutes.
</P>
<Button href={props.url}>Sign in to AFFiNE</Button>
</Content>
</Template>
);
}
@@ -0,0 +1,24 @@
import { Bold, Button, Content, P, Template, Title } from '../components';
export type SignUpProps = {
url: string;
};
export default function SignUp(props: SignUpProps) {
return (
<Template>
<Title>Create AFFiNE Account</Title>
<Content>
<P>
Click the button below to complete your account creation and sign in.
This magic link will expire in <Bold>30 minutes</Bold>.
</P>
<Button href={props.url}>Create account and sign in</Button>
</Content>
</Template>
);
}
SignUp.PreviewProps = {
url: 'https://app.affine.pro',
};
@@ -0,0 +1,30 @@
export { default as Invitation, type InvitationProps } from './invitation';
export {
default as InvitationAccepted,
type InvitationAcceptedProps,
} from './invitation-accepted';
export { default as MemberLeave, type MemberLeaveProps } from './member-leave';
export {
default as MemberRemoved,
type MemberRemovedProps,
} from './member-removed';
export {
default as OwnershipReceived,
type OwnershipReceivedProps,
} from './ownership-received';
export {
default as OwnershipTransferred,
type OwnershipTransferredProps,
} from './ownership-transferred';
export {
default as LinkInvitationApproved,
type LinkInvitationApprovedProps,
} from './review-approved';
export {
default as LinkInvitationReviewDeclined,
type LinkInvitationReviewDeclinedProps,
} from './review-declined';
export {
default as LinkInvitationReviewRequest,
type LinkInvitationReviewRequestProps,
} from './review-request';
@@ -0,0 +1,35 @@
import { TEST_USER, TEST_WORKSPACE } from '../common';
import {
Content,
P,
Template,
Title,
User,
type UserProps,
Workspace,
type WorkspaceProps,
} from '../components';
export type InvitationAcceptedProps = {
user: UserProps;
workspace: WorkspaceProps;
};
export default function InvitationAccepted(props: InvitationAcceptedProps) {
const { user, workspace } = props;
return (
<Template>
<Title>{user.email} accepted your invitation</Title>
<Content>
<P>
<User {...user} /> has joined <Workspace {...workspace} />
</P>
</Content>
</Template>
);
}
InvitationAccepted.PreviewProps = {
user: TEST_USER,
workspace: TEST_WORKSPACE,
};
@@ -0,0 +1,41 @@
import { TEST_USER, TEST_WORKSPACE } from '../common';
import {
Button,
Content,
P,
Template,
Title,
User,
type UserProps,
Workspace,
type WorkspaceProps,
} from '../components';
export type InvitationProps = {
user: UserProps;
workspace: WorkspaceProps;
url: string;
};
export default function Invitation(props: InvitationProps) {
const { user, workspace, url } = props;
return (
<Template>
<Title>You are invited!</Title>
<Content>
<P>
<User {...user} /> invited you to join <Workspace {...workspace} />
</P>
<P>Click button to join this workspace</P>
<Button href={url}>Accept & Join</Button>
</Content>
</Template>
);
}
Invitation.PreviewProps = {
user: TEST_USER,
workspace: TEST_WORKSPACE,
url: 'https://app.affine.pro',
};
@@ -0,0 +1,38 @@
import { TEST_USER, TEST_WORKSPACE } from '../common';
import {
Content,
Name,
P,
Template,
Title,
type UserProps,
Workspace,
type WorkspaceProps,
} from '../components';
export type MemberLeaveProps = {
user: UserProps;
workspace: WorkspaceProps;
};
export default function MemberLeave(props: MemberLeaveProps) {
const { user, workspace } = props;
return (
<Template>
<Title>
Member left <Workspace {...workspace} size={24} />
</Title>
<Content>
<P>
<Name>{user.email}</Name> has left workspace{' '}
<Workspace {...workspace} />
</P>
</Content>
</Template>
);
}
MemberLeave.PreviewProps = {
user: TEST_USER,
workspace: TEST_WORKSPACE,
};
@@ -0,0 +1,32 @@
import { TEST_WORKSPACE } from '../common';
import {
Content,
P,
Template,
Title,
Workspace,
type WorkspaceProps,
} from '../components';
export type MemberRemovedProps = {
workspace: WorkspaceProps;
};
export default function MemberRemoved(props: MemberRemovedProps) {
const { workspace } = props;
return (
<Template>
<Title>Workspace access removed</Title>
<Content>
<P>
You have been removed from <Workspace {...workspace} />. You no longer
have access to this workspace.
</P>
</Content>
</Template>
);
}
MemberRemoved.PreviewProps = {
workspace: TEST_WORKSPACE,
};
@@ -0,0 +1,34 @@
import { TEST_WORKSPACE } from '../common';
import {
Content,
P,
Template,
Title,
Workspace,
type WorkspaceProps,
} from '../components';
export type OwnershipReceivedProps = {
workspace: WorkspaceProps;
};
export default function OwnershipReceived(props: OwnershipReceivedProps) {
const { workspace } = props;
return (
<Template>
<Title>Welcome, new workspace owner!</Title>
<Content>
<P>
You have been assigned as the owner of
<Workspace {...workspace} />. As a workspace owner, you have full
control over this workspace.
</P>
</Content>
</Template>
);
}
OwnershipReceived.PreviewProps = {
workspace: TEST_WORKSPACE,
};
@@ -0,0 +1,32 @@
import { TEST_WORKSPACE } from '../common';
import {
Content,
P,
Template,
Title,
Workspace,
type WorkspaceProps,
} from '../components';
export type OwnershipTransferredProps = {
workspace: WorkspaceProps;
};
export default function OwnershipTransferred(props: OwnershipTransferredProps) {
const { workspace } = props;
return (
<Template>
<Title>Ownership transferred</Title>
<Content>
<P>
You have transferred ownership of <Workspace {...workspace} />. You
are now a collaborator in this workspace.
</P>
</Content>
</Template>
);
}
OwnershipTransferred.PreviewProps = {
workspace: TEST_WORKSPACE,
};
@@ -0,0 +1,39 @@
import { TEST_WORKSPACE } from '../common';
import {
Button,
Content,
P,
Template,
Title,
Workspace,
type WorkspaceProps,
} from '../components';
export type LinkInvitationApprovedProps = {
workspace: WorkspaceProps;
url: string;
};
export default function LinkInvitationApproved(
props: LinkInvitationApprovedProps
) {
const { workspace, url } = props;
return (
<Template>
<Title>Welcome to the workspace!</Title>
<Content>
<P>
Your request to join <Workspace {...workspace} /> has been accepted.
You can now access the team workspace and collaborate with other
members.
</P>
</Content>
<Button href={url}>Open Workspace</Button>
</Template>
);
}
LinkInvitationApproved.PreviewProps = {
workspace: TEST_WORKSPACE,
url: 'https://app.affine.pro',
};
@@ -0,0 +1,34 @@
import { TEST_WORKSPACE } from '../common';
import {
Content,
P,
Template,
Title,
Workspace,
type WorkspaceProps,
} from '../components';
export type LinkInvitationReviewDeclinedProps = {
workspace: WorkspaceProps;
};
export default function LinkInvitationReviewDeclined(
props: LinkInvitationReviewDeclinedProps
) {
const { workspace } = props;
return (
<Template>
<Title>Request declined</Title>
<Content>
<P>
Your request to join <Workspace {...workspace} /> has been declined by
the workspace admin.
</P>
</Content>
</Template>
);
}
LinkInvitationReviewDeclined.PreviewProps = {
workspace: TEST_WORKSPACE,
};
@@ -0,0 +1,45 @@
import { TEST_USER, TEST_WORKSPACE } from '../common';
import {
Button,
Content,
P,
Template,
Title,
User,
type UserProps,
Workspace,
type WorkspaceProps,
} from '../components';
export type LinkInvitationReviewRequestProps = {
workspace: WorkspaceProps;
user: UserProps;
url: string;
};
export default function LinkInvitationReviewRequest(
props: LinkInvitationReviewRequestProps
) {
const { workspace, user, url } = props;
return (
<Template>
<Title>
Request to join <Workspace {...workspace} size={24} />
</Title>
<Content>
<P>
<User {...user} /> has requested to join <Workspace {...workspace} />.
<br />
As a workspace owner/admin, you can approve or decline this request.
</P>
<Button href={url}>Review request</Button>
</Content>
</Template>
);
}
LinkInvitationReviewRequest.PreviewProps = {
workspace: TEST_WORKSPACE,
user: TEST_USER,
url: 'https://app.affine.pro',
};