feat: footer social links, cancel appointment button, Resend-ready docs
Made-with: Cursor
This commit is contained in:
parent
2d830fa8d2
commit
6c63b91afd
52
README.md
52
README.md
|
|
@ -38,36 +38,70 @@ All templates are wrapped with `BaseEmailShell`, which lives in `src/email/BaseE
|
||||||
|
|
||||||
### Using with Resend
|
### Using with Resend
|
||||||
|
|
||||||
These templates are written as plain React components so they can be adapted to Resend in several ways:
|
These templates are **ready to use with Resend**. They are plain React components that render to static HTML with:
|
||||||
|
|
||||||
1. **Copy JSX directly** into a dedicated Resend email project that uses React rendering (e.g. with `@react-email/components` or your own renderer).
|
- **Footer**: Social links (Instagram, X, YouTube, Facebook, LinkedIn), "Powered by Yaltopia Home", and a verification link. Override links via `socialLinks` on `BaseEmailShell`.
|
||||||
2. **Render to static HTML** in a Node script (using `react-dom/server`) and paste that HTML into Resend's dashboard as a custom template.
|
- **Logo**: Pass `logoUrl` (absolute URL) when sending so the logo loads in email clients (e.g. `https://yaltopia.home/logo.svg`).
|
||||||
The preview app now has an **HTML tab** which shows the full HTML (including `<!DOCTYPE html>`) for the currently selected template so you can copy it directly.
|
- **Appointment email**: Includes "Add to calendar" and **"Cancel appointment"** buttons; pass `calendarUrl` and `cancelUrl` from your backend.
|
||||||
3. Use this app only for visual QA, while you keep the production copies of these components in your backend or email-service repo.
|
|
||||||
|
|
||||||
When integrating, replace the sample props in `sampleData.ts` with real data and ensure links (payment, calendar, reset, etc.) are generated by your backend.
|
You can use them in two ways:
|
||||||
|
|
||||||
#### Example: sending via Resend with React templates
|
1. **React (recommended)** – Use the `react` option in `resend.emails.send()` and pass the template component with props. Resend will render to HTML.
|
||||||
|
2. **HTML** – Use the **HTML** tab in the preview app to copy the full HTML for a template, then use it as a custom template in Resend (replace placeholders with your variables).
|
||||||
|
|
||||||
|
When integrating, replace sample props with real data and generate all URLs (payment, calendar, cancel, reset) from your backend.
|
||||||
|
|
||||||
|
#### Example: sending via Resend with React
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { Resend } from 'resend'
|
import { Resend } from 'resend'
|
||||||
import { InvitationTeamMemberEmail } from './src/email/templates'
|
import { InvitationTeamMemberEmail, AppointmentBookedEmail } from './src/email/templates'
|
||||||
|
import { BaseEmailShell } from './src/email/BaseEmailShell'
|
||||||
|
|
||||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||||
|
|
||||||
|
// Optional: override footer social links and logo for all emails
|
||||||
|
const sharedShellProps = {
|
||||||
|
socialLinks: {
|
||||||
|
instagram: 'https://instagram.com/youraccount',
|
||||||
|
twitter: 'https://x.com/youraccount',
|
||||||
|
facebook: 'https://facebook.com/youraccount',
|
||||||
|
},
|
||||||
|
logoUrl: 'https://yaltopia.home/assets/logo.svg',
|
||||||
|
}
|
||||||
|
|
||||||
await resend.emails.send({
|
await resend.emails.send({
|
||||||
from: 'Yaltopia Home <no-reply@yaltopia.home>',
|
from: 'Yaltopia Home <no-reply@yaltopia.home>',
|
||||||
to: 'user@example.com',
|
to: 'user@example.com',
|
||||||
subject: 'You have been invited to Yaltopia Home',
|
subject: 'You have been invited to Yaltopia Home',
|
||||||
react: (
|
react: (
|
||||||
<InvitationTeamMemberEmail
|
<InvitationTeamMemberEmail
|
||||||
recipientName="Ricky Ricardo"
|
recipientName="Jane Doe"
|
||||||
teamName="Yaltopia Home Ops"
|
teamName="Yaltopia Home Ops"
|
||||||
role="Leasing Manager"
|
role="Leasing Manager"
|
||||||
inviteUrl="https://yaltopia.home/invite/abc123"
|
inviteUrl="https://yaltopia.home/invite/abc123"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Appointment with calendar + cancel
|
||||||
|
await resend.emails.send({
|
||||||
|
from: 'Yaltopia Home <no-reply@yaltopia.home>',
|
||||||
|
to: 'user@example.com',
|
||||||
|
subject: 'Your viewing appointment is confirmed',
|
||||||
|
react: (
|
||||||
|
<AppointmentBookedEmail
|
||||||
|
recipientName="Jane Doe"
|
||||||
|
date="February 15, 2026"
|
||||||
|
time="2:00 PM"
|
||||||
|
timezone="EAT"
|
||||||
|
location="123 Property St, Gate A"
|
||||||
|
agentName="Amara, Yaltopia Home"
|
||||||
|
calendarUrl="https://yaltopia.home/calendar/add/apt-123"
|
||||||
|
cancelUrl="https://yaltopia.home/appointments/apt-123/cancel"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,32 @@ export type EmailCategory =
|
||||||
| 'appointment'
|
| 'appointment'
|
||||||
| 'security'
|
| 'security'
|
||||||
|
|
||||||
|
/** Optional social links for the footer. Omit any you do not use. */
|
||||||
|
export interface SocialLinks {
|
||||||
|
instagram?: string
|
||||||
|
twitter?: string
|
||||||
|
youtube?: string
|
||||||
|
facebook?: string
|
||||||
|
linkedin?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SOCIAL_LINKS: SocialLinks = {
|
||||||
|
instagram: 'https://instagram.com/yaltopiahome',
|
||||||
|
twitter: 'https://x.com/yaltopiahome',
|
||||||
|
youtube: 'https://youtube.com/@yaltopiahome',
|
||||||
|
facebook: 'https://facebook.com/yaltopiahome',
|
||||||
|
linkedin: 'https://linkedin.com/company/yaltopiahome',
|
||||||
|
}
|
||||||
|
|
||||||
interface BaseEmailShellProps {
|
interface BaseEmailShellProps {
|
||||||
title: string
|
title: string
|
||||||
eyebrow?: string
|
eyebrow?: string
|
||||||
category: EmailCategory
|
category: EmailCategory
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
|
/** Override default social links in the footer. Pass from Resend with your real URLs. */
|
||||||
|
socialLinks?: SocialLinks
|
||||||
|
/** Full URL to the logo image. For Resend, use an absolute URL (e.g. https://yaltopia.home/logo.svg). */
|
||||||
|
logoUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoryLabel: Record<EmailCategory, string> = {
|
const categoryLabel: Record<EmailCategory, string> = {
|
||||||
|
|
@ -34,7 +55,17 @@ export function BaseEmailShell({
|
||||||
eyebrow,
|
eyebrow,
|
||||||
category,
|
category,
|
||||||
children,
|
children,
|
||||||
|
socialLinks: socialLinksProp,
|
||||||
|
logoUrl = '/YaltopiaHomesLogo.svg',
|
||||||
}: BaseEmailShellProps) {
|
}: BaseEmailShellProps) {
|
||||||
|
const links = { ...DEFAULT_SOCIAL_LINKS, ...socialLinksProp }
|
||||||
|
const hasSocial =
|
||||||
|
links.instagram ||
|
||||||
|
links.twitter ||
|
||||||
|
links.youtube ||
|
||||||
|
links.facebook ||
|
||||||
|
links.linkedin
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="email-root">
|
<div className="email-root">
|
||||||
<div className="email-shell">
|
<div className="email-shell">
|
||||||
|
|
@ -42,7 +73,7 @@ export function BaseEmailShell({
|
||||||
<div className="brand-lockup">
|
<div className="brand-lockup">
|
||||||
<div className="brand-mark">
|
<div className="brand-mark">
|
||||||
<img
|
<img
|
||||||
src="/YaltopiaHomesLogo.svg"
|
src={logoUrl}
|
||||||
alt="Yaltopia Home"
|
alt="Yaltopia Home"
|
||||||
className="brand-logo"
|
className="brand-logo"
|
||||||
/>
|
/>
|
||||||
|
|
@ -57,11 +88,40 @@ export function BaseEmailShell({
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
<footer className="email-footer">
|
<footer className="email-footer">
|
||||||
<div>
|
<div className="footer-left">
|
||||||
<div className="footer-brand">Yaltopia Home</div>
|
<div className="footer-brand">Yaltopia Home</div>
|
||||||
<div className="footer-meta">
|
<div className="footer-meta">
|
||||||
Transactional email · Please do not reply to this address.
|
Transactional email · Please do not reply to this address.
|
||||||
</div>
|
</div>
|
||||||
|
{hasSocial && (
|
||||||
|
<div className="footer-social">
|
||||||
|
{links.instagram && (
|
||||||
|
<a href={links.instagram} className="footer-social-link" target="_blank" rel="noopener noreferrer">
|
||||||
|
Instagram
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{links.twitter && (
|
||||||
|
<a href={links.twitter} className="footer-social-link" target="_blank" rel="noopener noreferrer">
|
||||||
|
X
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{links.youtube && (
|
||||||
|
<a href={links.youtube} className="footer-social-link" target="_blank" rel="noopener noreferrer">
|
||||||
|
YouTube
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{links.facebook && (
|
||||||
|
<a href={links.facebook} className="footer-social-link" target="_blank" rel="noopener noreferrer">
|
||||||
|
Facebook
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{links.linkedin && (
|
||||||
|
<a href={links.linkedin} className="footer-social-link" target="_blank" rel="noopener noreferrer">
|
||||||
|
LinkedIn
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="footer-powered">
|
<div className="footer-powered">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,27 @@ body {
|
||||||
color: #111827;
|
color: #111827;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.secondary-button {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||||
|
background: transparent;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row--secondary {
|
||||||
|
margin-top: -8px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.divider {
|
.divider {
|
||||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
margin: 16px 0 18px;
|
margin: 16px 0 18px;
|
||||||
|
|
@ -185,9 +206,16 @@ body {
|
||||||
background: #050505;
|
background: #050505;
|
||||||
color: #f9fafb;
|
color: #f9fafb;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-brand {
|
.footer-brand {
|
||||||
|
|
@ -201,9 +229,28 @@ body {
|
||||||
color: #d1d5db;
|
color: #d1d5db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer-social {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-social-link {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #9ca3af;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-social-link:hover {
|
||||||
|
color: #e5e7eb;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.footer-powered {
|
.footer-powered {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-approved {
|
.footer-approved {
|
||||||
|
|
|
||||||
|
|
@ -244,6 +244,7 @@ export const templates = [
|
||||||
location: '203 Yaltopia Crescent, Gate B',
|
location: '203 Yaltopia Crescent, Gate B',
|
||||||
agentName: 'Amara, Yaltopia Home',
|
agentName: 'Amara, Yaltopia Home',
|
||||||
calendarUrl: 'https://calendar.yaltopia.home/add/apt-0293',
|
calendarUrl: 'https://calendar.yaltopia.home/add/apt-0293',
|
||||||
|
cancelUrl: 'https://yaltopia.home/appointments/apt-0293/cancel',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -439,10 +439,12 @@ export interface AppointmentBookedEmailProps extends CommonEmailProps {
|
||||||
location: string
|
location: string
|
||||||
agentName: string
|
agentName: string
|
||||||
calendarUrl: string
|
calendarUrl: string
|
||||||
|
/** URL to cancel or manage the appointment (e.g. dashboard or cancel endpoint). */
|
||||||
|
cancelUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppointmentBookedEmail(props: AppointmentBookedEmailProps) {
|
export function AppointmentBookedEmail(props: AppointmentBookedEmailProps) {
|
||||||
const { recipientName, date, time, timezone, location, agentName, calendarUrl } =
|
const { recipientName, date, time, timezone, location, agentName, calendarUrl, cancelUrl } =
|
||||||
props
|
props
|
||||||
return (
|
return (
|
||||||
<BaseEmailShell
|
<BaseEmailShell
|
||||||
|
|
@ -480,9 +482,13 @@ export function AppointmentBookedEmail(props: AppointmentBookedEmailProps) {
|
||||||
Add to calendar
|
Add to calendar
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="button-row button-row--secondary">
|
||||||
|
<a href={cancelUrl} className="secondary-button">
|
||||||
|
Cancel appointment
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
<p className="body-text-muted">
|
<p className="body-text-muted">
|
||||||
Please arrive a few minutes early. You can reschedule or cancel from
|
Please arrive a few minutes early. You can reschedule from your dashboard.
|
||||||
your dashboard.
|
|
||||||
</p>
|
</p>
|
||||||
</BaseEmailShell>
|
</BaseEmailShell>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user