feat: add Gishen React Email demo with en/am templates
Ship 23 transactional templates, shared brand tokens/logo, preview UI, render API, CHANGELOG, and docs for Ecom/Admin/B2B consumers. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
8261920b9e
commit
f9fa3bc83b
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
node_modules
|
||||
.next
|
||||
out
|
||||
emails-export
|
||||
.DS_Store
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
25
CHANGELOG.md
Normal file
25
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.1.0] - 2026-08-07
|
||||
|
||||
### Added
|
||||
|
||||
- React Email + Next.js preview app for Gishen Pharmacy
|
||||
- Brand tokens and logo aligned with Gishen-B2B / Gishen-Admin
|
||||
- English and Amharic copy for all templates
|
||||
- Auth templates: welcome, password-reset, email-verify, user-invite
|
||||
- Order templates: confirmation, packing, out-for-delivery, delivered, cancelled
|
||||
- B2B templates: org-invite, join-code, approval-needed, statement-ready, credit-alert, organisation-verified
|
||||
- Prescription templates: submitted, decision, ready
|
||||
- Admin ops templates: low-stock, dispatch-assigned, procurement-alert
|
||||
- Marketing: promotional
|
||||
- Analytics: shop-summary (`daily` | `weekly` | `monthly` | `quarterly` | `yearly`)
|
||||
- HTML render API at `/api/email/[template]`
|
||||
- Design and implementation docs under `docs/superpowers/`
|
||||
|
||||
[0.1.0]: https://gitea.yaltopia.com/Gishen/Gishen-Email-Templates/releases/tag/v0.1.0
|
||||
89
README.md
89
README.md
|
|
@ -0,0 +1,89 @@
|
|||
# Gishen Email Templates
|
||||
|
||||
React Email templates and a Next.js preview app for **Gishen Pharmacy** — shared across Ecom, Admin, and B2B.
|
||||
|
||||
Brand colors and logo are taken from `Gishen-B2B` / `Gishen-Admin` (`#A8C73A`, `#141414`, `#E28A1A`, cream `#F6F7F2`).
|
||||
|
||||
## Features
|
||||
|
||||
- 23 templates (auth, orders, B2B, prescriptions, admin ops, marketing, shop analytics)
|
||||
- English + Amharic (`locale=en|am`)
|
||||
- Preview UI with category navigation, locale toggle, HTML download
|
||||
- Render API for demos / consumer apps
|
||||
- `shop-summary` with `period=daily|weekly|monthly|quarterly|yearly`
|
||||
|
||||
## Getting started
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run preview
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
### React Email CLI
|
||||
|
||||
```bash
|
||||
npm run email
|
||||
```
|
||||
|
||||
### Export HTML
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Render API
|
||||
|
||||
```
|
||||
GET /api/email/[template]?locale=en|am
|
||||
GET /api/email/shop-summary?locale=en&period=weekly
|
||||
```
|
||||
|
||||
Returns `text/html`.
|
||||
|
||||
## Templates
|
||||
|
||||
| ID | Category |
|
||||
|----|----------|
|
||||
| `welcome` | Auth |
|
||||
| `password-reset` | Auth |
|
||||
| `email-verify` | Auth |
|
||||
| `user-invite` | Auth |
|
||||
| `order-confirmation` | Orders |
|
||||
| `order-packing` | Orders |
|
||||
| `order-out-for-delivery` | Orders |
|
||||
| `order-delivered` | Orders |
|
||||
| `order-cancelled` | Orders |
|
||||
| `org-invite` | B2B |
|
||||
| `join-code` | B2B |
|
||||
| `approval-needed` | B2B |
|
||||
| `statement-ready` | B2B |
|
||||
| `credit-alert` | B2B |
|
||||
| `organisation-verified` | B2B |
|
||||
| `prescription-submitted` | Prescriptions |
|
||||
| `prescription-decision` | Prescriptions |
|
||||
| `prescription-ready` | Prescriptions |
|
||||
| `low-stock` | Admin ops |
|
||||
| `dispatch-assigned` | Admin ops |
|
||||
| `procurement-alert` | Admin ops |
|
||||
| `promotional` | Marketing |
|
||||
| `shop-summary` | Analytics |
|
||||
|
||||
## Consuming from Gishen apps
|
||||
|
||||
1. **Demo / staging:** call the render API and send the HTML through your ESP.
|
||||
2. **Production (later):** import templates from this package or copy exported HTML into your notification worker.
|
||||
|
||||
Do not hardcode brand colours in Ecom/Admin/B2B mailers — keep them here.
|
||||
|
||||
## Related repos
|
||||
|
||||
- [Gishen-Ecom](https://gitea.yaltopia.com/Gishen/Gishen-Ecom)
|
||||
- [Gishen-Admin](https://gitea.yaltopia.com/Gishen/Gishen-Admin)
|
||||
- [Gishen-B2B](https://gitea.yaltopia.com/Gishen/Gishen-B2B)
|
||||
- Structure inspired by Amba-Emails / Yaltopia Ticket Email
|
||||
|
||||
## License
|
||||
|
||||
Proprietary — Yaltopia / Gishen.
|
||||
38
app/api/email/[template]/route.ts
Normal file
38
app/api/email/[template]/route.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import React from 'react';
|
||||
import { render } from '@react-email/render';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { templateMap } from '../../../../emails/registry';
|
||||
import { resolveLocale } from '../../../../emails/i18n/types';
|
||||
import type { ShopSummaryPeriod } from '../../../../emails/shop-summary';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
context: { params: Promise<{ template: string }> }
|
||||
) {
|
||||
const { template } = await context.params;
|
||||
const entry = templateMap[template];
|
||||
|
||||
if (!entry) {
|
||||
return NextResponse.json({ error: 'Template not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const locale = resolveLocale(searchParams.get('locale') || undefined);
|
||||
const period = (searchParams.get('period') ||
|
||||
entry.defaultProps?.period ||
|
||||
'weekly') as ShopSummaryPeriod;
|
||||
|
||||
const html = await render(
|
||||
React.createElement(entry.component, {
|
||||
locale,
|
||||
...(entry.id === 'shop-summary' ? { period } : {}),
|
||||
...entry.defaultProps,
|
||||
})
|
||||
);
|
||||
|
||||
return new NextResponse(html, {
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
},
|
||||
});
|
||||
}
|
||||
24
app/layout.tsx
Normal file
24
app/layout.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { ReactNode } from 'react';
|
||||
import { theme } from '../emails/theme';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Gishen Email Templates',
|
||||
description: 'Preview transactional emails for Gishen Pharmacy',
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
style={{
|
||||
margin: 0,
|
||||
fontFamily: theme.fonts.sans,
|
||||
background: theme.colors.cream,
|
||||
color: theme.colors.forest,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
315
app/page.tsx
Normal file
315
app/page.tsx
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { templateCatalog, type TemplateCategory } from '../emails/registry';
|
||||
import { theme } from '../emails/theme';
|
||||
import type { Locale } from '../emails/i18n/types';
|
||||
import type { ShopSummaryPeriod } from '../emails/shop-summary';
|
||||
|
||||
const periods: ShopSummaryPeriod[] = [
|
||||
'daily',
|
||||
'weekly',
|
||||
'monthly',
|
||||
'quarterly',
|
||||
'yearly',
|
||||
];
|
||||
|
||||
export default function Home() {
|
||||
const [selectedId, setSelectedId] = useState(templateCatalog[0].id);
|
||||
const [locale, setLocale] = useState<Locale>('en');
|
||||
const [period, setPeriod] = useState<ShopSummaryPeriod>('weekly');
|
||||
const [htmlContent, setHtmlContent] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const selected = useMemo(
|
||||
() => templateCatalog.find((t) => t.id === selectedId) || templateCatalog[0],
|
||||
[selectedId]
|
||||
);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
return templateCatalog.reduce<Record<TemplateCategory, typeof templateCatalog>>(
|
||||
(acc, item) => {
|
||||
if (!acc[item.category]) acc[item.category] = [];
|
||||
acc[item.category].push(item);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<TemplateCategory, typeof templateCatalog>
|
||||
);
|
||||
}, []);
|
||||
|
||||
const loadTemplate = async (id: string, nextLocale: Locale, nextPeriod: ShopSummaryPeriod) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({ locale: nextLocale });
|
||||
if (id === 'shop-summary') params.set('period', nextPeriod);
|
||||
const response = await fetch(`/api/email/${id}?${params.toString()}`);
|
||||
const html = await response.text();
|
||||
setHtmlContent(html);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setHtmlContent('<p>Failed to render template.</p>');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplate(selectedId, locale, period);
|
||||
}, [selectedId, locale, period]);
|
||||
|
||||
const handleDownload = () => {
|
||||
const blob = new Blob([htmlContent], { type: 'text/html' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${selectedId}-${locale}.html`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={page}>
|
||||
<header style={header}>
|
||||
<div>
|
||||
<p style={eyebrow}>Gishen Pharmacy</p>
|
||||
<h1 style={title}>Email Templates</h1>
|
||||
<p style={subtitle}>
|
||||
Shared previews for Ecom, Admin, and B2B · brand colors & logo from Gishen apps
|
||||
</p>
|
||||
</div>
|
||||
<div style={controls}>
|
||||
<label style={controlLabel}>
|
||||
Locale
|
||||
<select
|
||||
value={locale}
|
||||
onChange={(e) => setLocale(e.target.value as Locale)}
|
||||
style={select}
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="am">አማርኛ</option>
|
||||
</select>
|
||||
</label>
|
||||
{selectedId === 'shop-summary' && (
|
||||
<label style={controlLabel}>
|
||||
Period
|
||||
<select
|
||||
value={period}
|
||||
onChange={(e) => setPeriod(e.target.value as ShopSummaryPeriod)}
|
||||
style={select}
|
||||
>
|
||||
{periods.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<button type="button" onClick={handleDownload} style={downloadBtn} disabled={loading}>
|
||||
Download HTML
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style={layout}>
|
||||
<aside style={sidebar}>
|
||||
{(Object.keys(grouped) as TemplateCategory[]).map((category) => (
|
||||
<div key={category} style={categoryBlock}>
|
||||
<h2 style={categoryTitle}>{category}</h2>
|
||||
<nav style={nav}>
|
||||
{grouped[category].map((template) => {
|
||||
const active = template.id === selected.id;
|
||||
return (
|
||||
<button
|
||||
key={template.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(template.id)}
|
||||
style={{
|
||||
...navButton,
|
||||
...(active ? activeNavButton : {}),
|
||||
}}
|
||||
>
|
||||
{template.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
<main style={previewPane}>
|
||||
<div style={previewHeader}>
|
||||
<strong>{selected.name}</strong>
|
||||
<span style={meta}>
|
||||
/api/email/{selected.id}?locale={locale}
|
||||
{selectedId === 'shop-summary' ? `&period=${period}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div style={loadingState}>Rendering…</div>
|
||||
) : (
|
||||
<iframe title={selected.name} srcDoc={htmlContent} style={iframe} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const page = {
|
||||
minHeight: '100vh',
|
||||
background: `linear-gradient(180deg, ${theme.colors.cream} 0%, #E8EDD8 100%)`,
|
||||
};
|
||||
|
||||
const header = {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: '24px',
|
||||
alignItems: 'flex-end',
|
||||
padding: '28px 32px 20px',
|
||||
borderBottom: `1px solid ${theme.colors.line}`,
|
||||
background: theme.colors.surface,
|
||||
flexWrap: 'wrap' as const,
|
||||
};
|
||||
|
||||
const eyebrow = {
|
||||
margin: 0,
|
||||
color: theme.colors.brandDeep,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.04em',
|
||||
textTransform: 'uppercase' as const,
|
||||
fontSize: '12px',
|
||||
};
|
||||
|
||||
const title = {
|
||||
margin: '6px 0 8px',
|
||||
fontSize: '32px',
|
||||
color: theme.colors.forest,
|
||||
};
|
||||
|
||||
const subtitle = {
|
||||
margin: 0,
|
||||
color: theme.colors.muted,
|
||||
maxWidth: '520px',
|
||||
};
|
||||
|
||||
const controls = {
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
alignItems: 'flex-end',
|
||||
flexWrap: 'wrap' as const,
|
||||
};
|
||||
|
||||
const controlLabel = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: '6px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: theme.colors.muted,
|
||||
};
|
||||
|
||||
const select = {
|
||||
minWidth: '140px',
|
||||
padding: '10px 12px',
|
||||
borderRadius: '8px',
|
||||
border: `1px solid ${theme.colors.line}`,
|
||||
background: theme.colors.surface,
|
||||
color: theme.colors.forest,
|
||||
};
|
||||
|
||||
const downloadBtn = {
|
||||
padding: '11px 16px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
background: theme.colors.forest,
|
||||
color: theme.colors.brand,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
const layout = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '280px 1fr',
|
||||
minHeight: 'calc(100vh - 140px)',
|
||||
};
|
||||
|
||||
const sidebar = {
|
||||
padding: '20px 16px 40px',
|
||||
borderRight: `1px solid ${theme.colors.line}`,
|
||||
background: 'rgba(255,255,255,0.7)',
|
||||
overflowY: 'auto' as const,
|
||||
maxHeight: 'calc(100vh - 140px)',
|
||||
};
|
||||
|
||||
const categoryBlock = {
|
||||
marginBottom: '18px',
|
||||
};
|
||||
|
||||
const categoryTitle = {
|
||||
margin: '0 0 8px',
|
||||
fontSize: '12px',
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.06em',
|
||||
color: theme.colors.muted,
|
||||
};
|
||||
|
||||
const nav = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: '4px',
|
||||
};
|
||||
|
||||
const navButton = {
|
||||
textAlign: 'left' as const,
|
||||
padding: '10px 12px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: theme.colors.forest,
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
};
|
||||
|
||||
const activeNavButton = {
|
||||
background: theme.colors.brand,
|
||||
color: theme.colors.forest,
|
||||
fontWeight: 700,
|
||||
};
|
||||
|
||||
const previewPane = {
|
||||
padding: '20px',
|
||||
};
|
||||
|
||||
const previewHeader = {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: '12px',
|
||||
alignItems: 'center',
|
||||
marginBottom: '12px',
|
||||
flexWrap: 'wrap' as const,
|
||||
};
|
||||
|
||||
const meta = {
|
||||
fontSize: '12px',
|
||||
color: theme.colors.muted,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
};
|
||||
|
||||
const iframe = {
|
||||
width: '100%',
|
||||
height: 'calc(100vh - 220px)',
|
||||
border: `1px solid ${theme.colors.line}`,
|
||||
borderRadius: '12px',
|
||||
background: theme.colors.surface,
|
||||
};
|
||||
|
||||
const loadingState = {
|
||||
height: 'calc(100vh - 220px)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
border: `1px dashed ${theme.colors.line}`,
|
||||
borderRadius: '12px',
|
||||
color: theme.colors.muted,
|
||||
};
|
||||
141
docs/superpowers/plans/2026-08-07-gishen-email-templates.md
Normal file
141
docs/superpowers/plans/2026-08-07-gishen-email-templates.md
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
# Gishen Email Templates Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Ship a React Email + Next.js demo app with 23 Gishen Pharmacy email templates (en/am), brand assets from Gishen-B2B/Admin, HTML render API, CHANGELOG, and Gitea remote setup.
|
||||
|
||||
**Architecture:** Mirror Amba-Emails: templates under `emails/`, shared theme/layout/components/i18n, Next preview page + `/api/email/[template]?locale=`, static logo in `public/brand/`.
|
||||
|
||||
**Tech Stack:** Next.js 14, React 18, `@react-email/components`, `@react-email/render`, `react-email`, TypeScript.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Brand colors exactly: brand `#A8C73A`, brandDeep `#8FAD2F`, forest `#141414`, accent `#E28A1A`, cream `#F6F7F2`, surface `#FFFFFF`, line `#E2E5D8`, muted `#5C5F56`
|
||||
- Product name: Gishen Pharmacy; footer email `info@gishenpharmacy.org`; site `https://gishenpharmacy.org`
|
||||
- Every template accepts `locale: 'en' | 'am'` (default `en`)
|
||||
- `shop-summary` is one template with `period: 'daily' | 'weekly' | 'monthly' | 'quarterly' | 'yearly'`
|
||||
- No SMTP/ESP sending in v1
|
||||
- CHANGELOG Keep a Changelog; version `0.1.0`
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
```
|
||||
Gishen-Email-Templates/
|
||||
package.json
|
||||
tsconfig.json
|
||||
next.config.js
|
||||
next-env.d.ts
|
||||
.gitignore
|
||||
README.md
|
||||
CHANGELOG.md
|
||||
public/brand/gishen-logo.png
|
||||
emails/
|
||||
theme.ts
|
||||
registry.ts
|
||||
utils/logoDataUri.ts
|
||||
i18n/types.ts
|
||||
i18n/common.ts
|
||||
i18n/templates/*.ts # string tables per template id
|
||||
components/{EmailLayout,Button,Card,StatusBanner,OrderSummary,MetricGrid}.tsx
|
||||
welcome.tsx … shop-summary.tsx
|
||||
app/
|
||||
layout.tsx
|
||||
page.tsx
|
||||
api/email/[template]/route.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Scaffold project + brand foundation
|
||||
|
||||
**Files:**
|
||||
- Create: `package.json`, `tsconfig.json`, `next.config.js`, `next-env.d.ts`, `.gitignore`
|
||||
- Create: `emails/theme.ts`, `emails/utils/logoDataUri.ts`
|
||||
- Create: `emails/components/EmailLayout.tsx`, `Button.tsx`, `Card.tsx`, `StatusBanner.tsx`, `OrderSummary.tsx`, `MetricGrid.tsx`
|
||||
- Create: `emails/i18n/types.ts`, `emails/i18n/common.ts`
|
||||
- Copy: `public/brand/gishen-logo.png` from Gishen-B2B
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `theme`, `Locale`, `tCommon(locale)`, `EmailLayout`, `Button`, `Card`, `StatusBanner`, `OrderSummary`, `MetricGrid`, `logoDataUri`
|
||||
|
||||
- [ ] **Step 1: Create package.json and configs** (same deps as Amba-Emails)
|
||||
|
||||
- [ ] **Step 2: Copy logo and write theme + logoDataUri (node base64)**
|
||||
|
||||
- [ ] **Step 3: Implement layout + shared components using Gishen colors**
|
||||
|
||||
- [ ] **Step 4: `npm install` and confirm no errors**
|
||||
|
||||
- [ ] **Step 5: Commit** `chore: scaffold Gishen email project and brand tokens`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: i18n tables + all 23 templates + registry
|
||||
|
||||
**Files:**
|
||||
- Create: `emails/i18n/templates/*.ts` (one module or grouped modules)
|
||||
- Create: 23 template files listed in the design spec
|
||||
- Create: `emails/registry.ts` mapping id → component + default demo props
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: layout/components/theme/`Locale`
|
||||
- Produces: `templates` registry `Record<string, { component, category, name, defaultProps }>`
|
||||
- Each email: `export function XEmail(props: { locale?: Locale; ... })`
|
||||
- `shop-summary` props include `period: 'daily'|'weekly'|'monthly'|'quarterly'|'yearly'`
|
||||
|
||||
- [ ] **Step 1: Write i18n string tables for en/am for all templates**
|
||||
|
||||
- [ ] **Step 2: Implement each template using shared components (Ethiopian pharmacy sample data)**
|
||||
|
||||
- [ ] **Step 3: Export registry of all template ids**
|
||||
|
||||
- [ ] **Step 4: Commit** `feat: add Gishen email templates with en/am copy`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Next preview app + render API
|
||||
|
||||
**Files:**
|
||||
- Create: `app/layout.tsx`, `app/page.tsx`, `app/api/email/[template]/route.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- `GET /api/email/[template]?locale=en|am` → `text/html`
|
||||
- Preview: category sidebar, locale toggle, download HTML
|
||||
|
||||
- [ ] **Step 1: Implement API route using registry + `@react-email/render`**
|
||||
|
||||
- [ ] **Step 2: Implement preview page with locale toggle**
|
||||
|
||||
- [ ] **Step 3: Run `npm run preview`, hit `/` and one API URL, confirm HTML**
|
||||
|
||||
- [ ] **Step 4: Commit** `feat: add preview UI and HTML render API`
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Docs, changelog, remote
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
- Create: `CHANGELOG.md`
|
||||
|
||||
- [ ] **Step 1: Document templates, props, scripts, consumer notes**
|
||||
|
||||
- [ ] **Step 2: CHANGELOG 0.1.0 entry listing template set**
|
||||
|
||||
- [ ] **Step 3: Commit** `docs: add README and CHANGELOG for 0.1.0`
|
||||
|
||||
- [ ] **Step 4: `git remote add origin …` and `git push -u origin main` if credentials allow**
|
||||
|
||||
---
|
||||
|
||||
## Spec coverage checklist
|
||||
|
||||
- [x] Amba-Emails stack
|
||||
- [x] Brand colors + logo
|
||||
- [x] en + am
|
||||
- [x] All template categories + organisation-verified + shop-summary period prop
|
||||
- [x] Preview + API
|
||||
- [x] CHANGELOG + README
|
||||
- [x] Git remote/push
|
||||
46
emails/approval-needed.tsx
Normal file
46
emails/approval-needed.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { Heading, Section, Text } from '@react-email/components';
|
||||
import { EmailLayout } from './components/EmailLayout';
|
||||
import { Button } from './components/Button';
|
||||
import { StatusBanner } from './components/StatusBanner';
|
||||
import { styles } from './components/styles';
|
||||
import { tCommon } from './i18n/common';
|
||||
import { tTemplate } from './i18n/templates';
|
||||
import { resolveLocale, type Locale } from './i18n/types';
|
||||
import { interpolate } from './utils/interpolate';
|
||||
import { theme } from './theme';
|
||||
|
||||
export interface ApprovalNeededEmailProps {
|
||||
locale?: Locale;
|
||||
approverName?: string;
|
||||
requesterName?: string;
|
||||
requestType?: string;
|
||||
orgName?: string;
|
||||
ctaUrl?: string;
|
||||
}
|
||||
|
||||
export const ApprovalNeededEmail = ({
|
||||
locale,
|
||||
approverName = 'Finance Lead',
|
||||
requesterName = 'Meron Hailu',
|
||||
requestType = 'credit top-up',
|
||||
orgName = 'Yaltopia Tech',
|
||||
ctaUrl = `${theme.brand.website}/b2b/finance/approvals`,
|
||||
}: ApprovalNeededEmailProps) => {
|
||||
const l = resolveLocale(locale);
|
||||
const t = tTemplate('approval-needed', l);
|
||||
const common = tCommon(l);
|
||||
return (
|
||||
<EmailLayout preview={interpolate(t.preview, { requestType })} locale={l}>
|
||||
<StatusBanner tone="warning">{t.banner}</StatusBanner>
|
||||
<Heading style={styles.heading}>{t.heading}</Heading>
|
||||
<Text style={styles.text}>{interpolate(t.body1, { approverName, requesterName, requestType, orgName })}</Text>
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href={ctaUrl} variant="accent">{t.cta}</Button>
|
||||
</Section>
|
||||
<Text style={styles.muted}>{common.regards}<br />{common.team}</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
ApprovalNeededEmail.PreviewProps = { locale: 'en' } as ApprovalNeededEmailProps;
|
||||
export default ApprovalNeededEmail;
|
||||
34
emails/components/Button.tsx
Normal file
34
emails/components/Button.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import React from 'react';
|
||||
import { Button as ReactEmailButton } from '@react-email/components';
|
||||
import { theme } from '../theme';
|
||||
|
||||
interface ButtonProps {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
variant?: 'primary' | 'accent';
|
||||
}
|
||||
|
||||
export const Button = ({ href, children, variant = 'primary' }: ButtonProps) => {
|
||||
const backgroundColor =
|
||||
variant === 'accent' ? theme.colors.accent : theme.colors.brand;
|
||||
const color = variant === 'accent' ? theme.colors.surface : theme.colors.forest;
|
||||
|
||||
return (
|
||||
<ReactEmailButton
|
||||
href={href}
|
||||
style={{
|
||||
backgroundColor,
|
||||
borderRadius: '8px',
|
||||
color,
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
textDecoration: 'none',
|
||||
textAlign: 'center' as const,
|
||||
display: 'inline-block',
|
||||
padding: '12px 24px',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ReactEmailButton>
|
||||
);
|
||||
};
|
||||
19
emails/components/Card.tsx
Normal file
19
emails/components/Card.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import React from 'react';
|
||||
import { Section } from '@react-email/components';
|
||||
import { theme } from '../theme';
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Card = ({ children }: CardProps) => (
|
||||
<Section style={card}>{children}</Section>
|
||||
);
|
||||
|
||||
const card = {
|
||||
backgroundColor: theme.colors.cream,
|
||||
border: `1px solid ${theme.colors.line}`,
|
||||
borderRadius: '10px',
|
||||
padding: '16px 18px',
|
||||
margin: '0 0 20px',
|
||||
};
|
||||
128
emails/components/EmailLayout.tsx
Normal file
128
emails/components/EmailLayout.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
} from '@react-email/components';
|
||||
import { theme } from '../theme';
|
||||
import { logoDataUri } from '../utils/logoDataUri';
|
||||
import { tCommon } from '../i18n/common';
|
||||
import type { Locale } from '../i18n/types';
|
||||
|
||||
interface EmailLayoutProps {
|
||||
preview: string;
|
||||
children: React.ReactNode;
|
||||
locale?: Locale;
|
||||
logoUrl?: string;
|
||||
}
|
||||
|
||||
export const EmailLayout = ({
|
||||
preview,
|
||||
children,
|
||||
locale = 'en',
|
||||
logoUrl = logoDataUri,
|
||||
}: EmailLayoutProps) => {
|
||||
const common = tCommon(locale);
|
||||
|
||||
return (
|
||||
<Html lang={locale}>
|
||||
<Head>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</Head>
|
||||
<Preview>{preview}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Section style={header}>
|
||||
<Img
|
||||
src={logoUrl}
|
||||
width="180"
|
||||
height="42"
|
||||
alt={theme.brand.name}
|
||||
style={logo}
|
||||
/>
|
||||
</Section>
|
||||
<Section style={content}>{children}</Section>
|
||||
<Section style={footer}>
|
||||
<Text style={footerText}>
|
||||
© {new Date().getFullYear()} {theme.brand.name}. {common.allRights}
|
||||
</Text>
|
||||
<Text style={footerText}>
|
||||
{theme.brand.address} · {theme.brand.phone}
|
||||
</Text>
|
||||
<Text style={footerText}>
|
||||
<Link href={`${theme.brand.website}/privacy`} style={footerLink}>
|
||||
{common.privacy}
|
||||
</Link>
|
||||
{' · '}
|
||||
<Link href={`${theme.brand.website}/terms`} style={footerLink}>
|
||||
{common.terms}
|
||||
</Link>
|
||||
{' · '}
|
||||
<Link href={`mailto:${theme.brand.email}`} style={footerLink}>
|
||||
{theme.brand.email}
|
||||
</Link>
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
const main = {
|
||||
backgroundColor: theme.colors.cream,
|
||||
fontFamily: theme.fonts.sans,
|
||||
};
|
||||
|
||||
const container = {
|
||||
margin: '0 auto',
|
||||
padding: '24px 0 48px',
|
||||
maxWidth: '600px',
|
||||
};
|
||||
|
||||
const header = {
|
||||
padding: '28px 24px',
|
||||
textAlign: 'center' as const,
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderTop: `4px solid ${theme.colors.brand}`,
|
||||
borderRadius: '12px 12px 0 0',
|
||||
};
|
||||
|
||||
const logo = {
|
||||
margin: '0 auto',
|
||||
};
|
||||
|
||||
const content = {
|
||||
backgroundColor: theme.colors.surface,
|
||||
padding: '8px 28px 32px',
|
||||
borderLeft: `1px solid ${theme.colors.line}`,
|
||||
borderRight: `1px solid ${theme.colors.line}`,
|
||||
};
|
||||
|
||||
const footer = {
|
||||
backgroundColor: theme.colors.forest,
|
||||
padding: '24px 28px',
|
||||
borderRadius: '0 0 12px 12px',
|
||||
textAlign: 'center' as const,
|
||||
};
|
||||
|
||||
const footerText = {
|
||||
color: '#C5C9B8',
|
||||
fontSize: '12px',
|
||||
lineHeight: '18px',
|
||||
margin: '0 0 8px',
|
||||
};
|
||||
|
||||
const footerLink = {
|
||||
color: theme.colors.brand,
|
||||
textDecoration: 'none',
|
||||
};
|
||||
44
emails/components/MetricGrid.tsx
Normal file
44
emails/components/MetricGrid.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import React from 'react';
|
||||
import { Column, Row, Text } from '@react-email/components';
|
||||
import { theme } from '../theme';
|
||||
import { Card } from './Card';
|
||||
|
||||
export interface Metric {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface MetricGridProps {
|
||||
metrics: Metric[];
|
||||
}
|
||||
|
||||
export const MetricGrid = ({ metrics }: MetricGridProps) => (
|
||||
<Card>
|
||||
<Row>
|
||||
{metrics.map((metric) => (
|
||||
<Column key={metric.label} style={col}>
|
||||
<Text style={value}>{metric.value}</Text>
|
||||
<Text style={label}>{metric.label}</Text>
|
||||
</Column>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const col = {
|
||||
padding: '8px',
|
||||
textAlign: 'center' as const,
|
||||
};
|
||||
|
||||
const value = {
|
||||
color: theme.colors.forest,
|
||||
fontSize: '20px',
|
||||
fontWeight: 700,
|
||||
margin: '0 0 4px',
|
||||
};
|
||||
|
||||
const label = {
|
||||
color: theme.colors.muted,
|
||||
fontSize: '12px',
|
||||
margin: 0,
|
||||
};
|
||||
78
emails/components/OrderSummary.tsx
Normal file
78
emails/components/OrderSummary.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import React from 'react';
|
||||
import { Column, Row, Section, Text } from '@react-email/components';
|
||||
import { theme } from '../theme';
|
||||
import { Card } from './Card';
|
||||
|
||||
export interface OrderLineItem {
|
||||
name: string;
|
||||
qty: number;
|
||||
price: string;
|
||||
}
|
||||
|
||||
interface OrderSummaryProps {
|
||||
orderId: string;
|
||||
items: OrderLineItem[];
|
||||
total: string;
|
||||
orderLabel: string;
|
||||
totalLabel: string;
|
||||
}
|
||||
|
||||
export const OrderSummary = ({
|
||||
orderId,
|
||||
items,
|
||||
total,
|
||||
orderLabel,
|
||||
totalLabel,
|
||||
}: OrderSummaryProps) => (
|
||||
<Card>
|
||||
<Text style={label}>
|
||||
{orderLabel}: {orderId}
|
||||
</Text>
|
||||
{items.map((item) => (
|
||||
<Row key={`${item.name}-${item.qty}`} style={row}>
|
||||
<Column>
|
||||
<Text style={itemText}>
|
||||
{item.name} × {item.qty}
|
||||
</Text>
|
||||
</Column>
|
||||
<Column align="right">
|
||||
<Text style={itemText}>{item.price}</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
))}
|
||||
<Section style={totalRow}>
|
||||
<Text style={totalText}>
|
||||
{totalLabel}: {total}
|
||||
</Text>
|
||||
</Section>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const label = {
|
||||
color: theme.colors.muted,
|
||||
fontSize: '13px',
|
||||
margin: '0 0 12px',
|
||||
};
|
||||
|
||||
const row = {
|
||||
marginBottom: '6px',
|
||||
};
|
||||
|
||||
const itemText = {
|
||||
color: theme.colors.forest,
|
||||
fontSize: '14px',
|
||||
margin: 0,
|
||||
};
|
||||
|
||||
const totalRow = {
|
||||
borderTop: `1px solid ${theme.colors.line}`,
|
||||
marginTop: '12px',
|
||||
paddingTop: '12px',
|
||||
};
|
||||
|
||||
const totalText = {
|
||||
color: theme.colors.forest,
|
||||
fontSize: '16px',
|
||||
fontWeight: 700,
|
||||
margin: 0,
|
||||
};
|
||||
65
emails/components/StatusBanner.tsx
Normal file
65
emails/components/StatusBanner.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import React from 'react';
|
||||
import { Section, Text } from '@react-email/components';
|
||||
import { theme } from '../theme';
|
||||
|
||||
type Tone = 'success' | 'warning' | 'danger' | 'info' | 'brand';
|
||||
|
||||
interface StatusBannerProps {
|
||||
tone?: Tone;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const tones: Record<Tone, { bg: string; fg: string; border: string }> = {
|
||||
success: {
|
||||
bg: '#EEF6E8',
|
||||
fg: theme.colors.success,
|
||||
border: theme.colors.brand,
|
||||
},
|
||||
warning: {
|
||||
bg: '#FFF4E8',
|
||||
fg: theme.colors.accent,
|
||||
border: theme.colors.accent,
|
||||
},
|
||||
danger: {
|
||||
bg: '#FDECEA',
|
||||
fg: theme.colors.danger,
|
||||
border: theme.colors.danger,
|
||||
},
|
||||
info: {
|
||||
bg: '#EAF1F8',
|
||||
fg: theme.colors.info,
|
||||
border: theme.colors.info,
|
||||
},
|
||||
brand: {
|
||||
bg: '#F4F8E4',
|
||||
fg: theme.colors.forest,
|
||||
border: theme.colors.brand,
|
||||
},
|
||||
};
|
||||
|
||||
export const StatusBanner = ({ tone = 'brand', children }: StatusBannerProps) => {
|
||||
const t = tones[tone];
|
||||
return (
|
||||
<Section
|
||||
style={{
|
||||
backgroundColor: t.bg,
|
||||
borderLeft: `4px solid ${t.border}`,
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
margin: '0 0 20px',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: t.fg,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
margin: 0,
|
||||
lineHeight: '20px',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
27
emails/components/styles.ts
Normal file
27
emails/components/styles.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { theme } from '../theme';
|
||||
|
||||
export const styles = {
|
||||
heading: {
|
||||
color: theme.colors.forest,
|
||||
fontSize: '26px',
|
||||
fontWeight: '700' as const,
|
||||
margin: '0 0 16px',
|
||||
lineHeight: '32px',
|
||||
},
|
||||
text: {
|
||||
color: theme.colors.forest,
|
||||
fontSize: '16px',
|
||||
lineHeight: '24px',
|
||||
margin: '0 0 16px',
|
||||
},
|
||||
muted: {
|
||||
color: theme.colors.muted,
|
||||
fontSize: '14px',
|
||||
lineHeight: '22px',
|
||||
margin: '0 0 12px',
|
||||
},
|
||||
buttonSection: {
|
||||
textAlign: 'center' as const,
|
||||
margin: '28px 0',
|
||||
},
|
||||
};
|
||||
47
emails/credit-alert.tsx
Normal file
47
emails/credit-alert.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { Heading, Section, Text } from '@react-email/components';
|
||||
import { EmailLayout } from './components/EmailLayout';
|
||||
import { Button } from './components/Button';
|
||||
import { StatusBanner } from './components/StatusBanner';
|
||||
import { styles } from './components/styles';
|
||||
import { tCommon } from './i18n/common';
|
||||
import { tTemplate } from './i18n/templates';
|
||||
import { resolveLocale, type Locale } from './i18n/types';
|
||||
import { interpolate } from './utils/interpolate';
|
||||
import { theme } from './theme';
|
||||
|
||||
export interface CreditAlertEmailProps {
|
||||
locale?: Locale;
|
||||
userName?: string;
|
||||
orgName?: string;
|
||||
usedPercent?: string;
|
||||
availableCredit?: string;
|
||||
ctaUrl?: string;
|
||||
}
|
||||
|
||||
export const CreditAlertEmail = ({
|
||||
locale,
|
||||
userName = 'Helen Tadesse',
|
||||
orgName = 'Yaltopia Tech',
|
||||
usedPercent = '82',
|
||||
availableCredit = 'ETB 90,000',
|
||||
ctaUrl = `${theme.brand.website}/b2b/finance`,
|
||||
}: CreditAlertEmailProps) => {
|
||||
const l = resolveLocale(locale);
|
||||
const t = tTemplate('credit-alert', l);
|
||||
const common = tCommon(l);
|
||||
return (
|
||||
<EmailLayout preview={interpolate(t.preview, { orgName })} locale={l}>
|
||||
<StatusBanner tone="warning">{interpolate(t.banner, { availableCredit })}</StatusBanner>
|
||||
<Heading style={styles.heading}>{t.heading}</Heading>
|
||||
<Text style={styles.text}>{interpolate(t.body1, { userName, orgName, usedPercent })}</Text>
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href={ctaUrl} variant="accent">{t.cta}</Button>
|
||||
</Section>
|
||||
<Text style={styles.muted}>{t.body2}</Text>
|
||||
<Text style={styles.muted}>{common.regards}<br />{common.team}</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
CreditAlertEmail.PreviewProps = { locale: 'en' } as CreditAlertEmailProps;
|
||||
export default CreditAlertEmail;
|
||||
46
emails/dispatch-assigned.tsx
Normal file
46
emails/dispatch-assigned.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { Heading, Section, Text } from '@react-email/components';
|
||||
import { EmailLayout } from './components/EmailLayout';
|
||||
import { Button } from './components/Button';
|
||||
import { StatusBanner } from './components/StatusBanner';
|
||||
import { styles } from './components/styles';
|
||||
import { tCommon } from './i18n/common';
|
||||
import { tTemplate } from './i18n/templates';
|
||||
import { resolveLocale, type Locale } from './i18n/types';
|
||||
import { interpolate } from './utils/interpolate';
|
||||
import { theme } from './theme';
|
||||
|
||||
export interface DispatchAssignedEmailProps {
|
||||
locale?: Locale;
|
||||
orderId?: string;
|
||||
riderName?: string;
|
||||
branchName?: string;
|
||||
pickupWindow?: string;
|
||||
ctaUrl?: string;
|
||||
}
|
||||
|
||||
export const DispatchAssignedEmail = ({
|
||||
locale,
|
||||
orderId = 'GS-10482',
|
||||
riderName = 'Dawit Lemma',
|
||||
branchName = 'Bole Branch',
|
||||
pickupWindow = '16:00–16:30',
|
||||
ctaUrl = `${theme.brand.website}/admin/dispatch`,
|
||||
}: DispatchAssignedEmailProps) => {
|
||||
const l = resolveLocale(locale);
|
||||
const t = tTemplate('dispatch-assigned', l);
|
||||
const common = tCommon(l);
|
||||
return (
|
||||
<EmailLayout preview={interpolate(t.preview, { orderId })} locale={l}>
|
||||
<StatusBanner tone="brand">{interpolate(t.banner, { pickupWindow })}</StatusBanner>
|
||||
<Heading style={styles.heading}>{t.heading}</Heading>
|
||||
<Text style={styles.text}>{interpolate(t.body1, { orderId, riderName, branchName })}</Text>
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href={ctaUrl}>{t.cta}</Button>
|
||||
</Section>
|
||||
<Text style={styles.muted}>{common.regards}<br />{common.team}</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
DispatchAssignedEmail.PreviewProps = { locale: 'en' } as DispatchAssignedEmailProps;
|
||||
export default DispatchAssignedEmail;
|
||||
41
emails/email-verify.tsx
Normal file
41
emails/email-verify.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { Heading, Section, Text } from '@react-email/components';
|
||||
import { EmailLayout } from './components/EmailLayout';
|
||||
import { Button } from './components/Button';
|
||||
import { styles } from './components/styles';
|
||||
import { tCommon } from './i18n/common';
|
||||
import { tTemplate } from './i18n/templates';
|
||||
import { resolveLocale, type Locale } from './i18n/types';
|
||||
import { interpolate } from './utils/interpolate';
|
||||
import { theme } from './theme';
|
||||
|
||||
export interface EmailVerifyEmailProps {
|
||||
locale?: Locale;
|
||||
userName?: string;
|
||||
email?: string;
|
||||
verifyUrl?: string;
|
||||
}
|
||||
|
||||
export const EmailVerifyEmail = ({
|
||||
locale,
|
||||
userName = 'Abebe Kebede',
|
||||
email = 'abebe@yaltopia.com',
|
||||
verifyUrl = `${theme.brand.website}/verify`,
|
||||
}: EmailVerifyEmailProps) => {
|
||||
const l = resolveLocale(locale);
|
||||
const t = tTemplate('email-verify', l);
|
||||
const common = tCommon(l);
|
||||
return (
|
||||
<EmailLayout preview={t.preview} locale={l}>
|
||||
<Heading style={styles.heading}>{t.heading}</Heading>
|
||||
<Text style={styles.text}>{interpolate(t.body1, { userName, email })}</Text>
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href={verifyUrl}>{t.cta}</Button>
|
||||
</Section>
|
||||
<Text style={styles.muted}>{t.body2}</Text>
|
||||
<Text style={styles.muted}>{common.regards}<br />{common.team}</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
EmailVerifyEmail.PreviewProps = { locale: 'en' } as EmailVerifyEmailProps;
|
||||
export default EmailVerifyEmail;
|
||||
29
emails/i18n/common.ts
Normal file
29
emails/i18n/common.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import type { Locale } from './types';
|
||||
import { theme } from '../theme';
|
||||
|
||||
const common = {
|
||||
en: {
|
||||
privacy: 'Privacy Policy',
|
||||
terms: 'Terms of Service',
|
||||
support: 'Need help?',
|
||||
contactSupport: 'Contact support',
|
||||
regards: 'Best regards,',
|
||||
team: `The ${theme.brand.name} Team`,
|
||||
viewInBrowser: 'View in browser',
|
||||
allRights: 'All rights reserved.',
|
||||
},
|
||||
am: {
|
||||
privacy: 'የግላዊነት ፖሊሲ',
|
||||
terms: 'የአገልግሎት ውሎች',
|
||||
support: 'እገዛ ይፈልጋሉ?',
|
||||
contactSupport: 'ድጋፍ ያግኙ',
|
||||
regards: 'ከሰላምታ ጋር፣',
|
||||
team: `የ${theme.brand.name} ቡድን`,
|
||||
viewInBrowser: 'በአሳሽ ውስጥ ይመልከቱ',
|
||||
allRights: 'መብቱ በህግ የተጠበቀ ነው።',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function tCommon(locale: Locale) {
|
||||
return common[locale];
|
||||
}
|
||||
412
emails/i18n/templates/index.ts
Normal file
412
emails/i18n/templates/index.ts
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
import type { Locale } from '../types';
|
||||
|
||||
export const templateCopy = {
|
||||
"welcome": {
|
||||
"en": {
|
||||
"preview": "Welcome to Gishen Pharmacy",
|
||||
"heading": "Welcome, {userName}",
|
||||
"body1": "Your Gishen Pharmacy account is ready. Order medicines, manage prescriptions, and track deliveries from one place.",
|
||||
"body2": "Start by browsing the shop or linking your organisation benefits if your employer covers pharmacy care.",
|
||||
"cta": "Open Gishen",
|
||||
"closing": "We’re glad you’re here."
|
||||
},
|
||||
"am": {
|
||||
"preview": "እንኳን ወደ ጊሼን ፋርማሲ በደህና መጡ",
|
||||
"heading": "እንኳን ደህና መጡ፣ {userName}",
|
||||
"body1": "የጊሼን ፋርማሲ መለያዎ ዝግጁ ነው። መድሃኒት ይዘዙ፣ የመድሃኒት ማዘዣዎችን ያስተዳድሩ እና ማድረሶችን ከአንድ ቦታ ይከታተሉ።",
|
||||
"body2": "ሱቁን በመድረስ ወይም አሰሪዎ የፋርማሲ ጥቅማጥቅሞች ካሉ መገልገያዎችን በማገናኘት ይጀምሩ።",
|
||||
"cta": "ጊሼንን ክፈት",
|
||||
"closing": "እዚህ ስላሉ ደስ ብሎናል።"
|
||||
}
|
||||
},
|
||||
"password-reset": {
|
||||
"en": {
|
||||
"preview": "Reset your Gishen password",
|
||||
"heading": "Reset your password",
|
||||
"body1": "We received a request to reset the password for {userName}. This link expires in 60 minutes.",
|
||||
"cta": "Choose a new password",
|
||||
"body2": "If you did not request this, you can ignore this email. Your password will stay the same.",
|
||||
"banner": "Security notice"
|
||||
},
|
||||
"am": {
|
||||
"preview": "የጊሼን የይለፍ ቃልዎን ያደሱ",
|
||||
"heading": "የይለፍ ቃልዎን ያድሱ",
|
||||
"body1": "ለ{userName} የይለፍ ቃል ማደስ ጥያቄ ደርሶናል። ይህ አገናኝ በ60 ደቂቃ ውስጥ ጊዜው ያልፍበታል።",
|
||||
"cta": "አዲስ የይለፍ ቃል ይምረጡ",
|
||||
"body2": "ይህን ካልጠየቁ ይህን ኢሜይል ችላ ማለት ይችላሉ። የይለፍ ቃልዎ አይለወጥም።",
|
||||
"banner": "የደህንነት ማስታወቂያ"
|
||||
}
|
||||
},
|
||||
"email-verify": {
|
||||
"en": {
|
||||
"preview": "Verify your email for Gishen",
|
||||
"heading": "Confirm your email",
|
||||
"body1": "Hi {userName}, please verify {email} to activate your Gishen Pharmacy account.",
|
||||
"cta": "Verify email",
|
||||
"body2": "This helps us keep prescriptions and order updates secure."
|
||||
},
|
||||
"am": {
|
||||
"preview": "ለጊሼን ኢሜይልዎን ያረጋግጡ",
|
||||
"heading": "ኢሜይልዎን ያረጋግጡ",
|
||||
"body1": "ሰላም {userName}፣ የጊሼን ፋርማሲ መለያዎን ለማንቃት {email}ን እባክዎ ያረጋግጡ።",
|
||||
"cta": "ኢሜይል አረጋግጥ",
|
||||
"body2": "ይህ የመድሃኒት ማዘዣዎችን እና የትዕዛዝ ዝመናዎችን ደህንነቱ የተጠበቀ ያደርገዋል።"
|
||||
}
|
||||
},
|
||||
"user-invite": {
|
||||
"en": {
|
||||
"preview": "You’re invited to Gishen",
|
||||
"heading": "Join {orgName} on Gishen",
|
||||
"body1": "{inviterName} invited you to access Gishen Pharmacy as {role}.",
|
||||
"cta": "Accept invitation",
|
||||
"body2": "The invite expires in 7 days."
|
||||
},
|
||||
"am": {
|
||||
"preview": "ወደ ጊሼን ተጋብዘዋል",
|
||||
"heading": "በጊሼን ላይ {orgName}ን ይቀላቀሉ",
|
||||
"body1": "{inviterName} እንደ {role} ወደ ጊሼን ፋርማሲ እንዲገቡ ጋብዘዎታል።",
|
||||
"cta": "ግብዣ ተቀበል",
|
||||
"body2": "ግብዣው በ7 ቀናት ውስጥ ጊዜው ያልፍበታል።"
|
||||
}
|
||||
},
|
||||
"order-confirmation": {
|
||||
"en": {
|
||||
"preview": "Order {orderId} confirmed",
|
||||
"heading": "Order confirmed",
|
||||
"body1": "Thanks {userName}. We received order {orderId} and the pharmacy team is reviewing it.",
|
||||
"orderLabel": "Order",
|
||||
"totalLabel": "Total",
|
||||
"cta": "Track order",
|
||||
"banner": "We’ll email you when packing starts."
|
||||
},
|
||||
"am": {
|
||||
"preview": "ትዕዛዝ {orderId} ተረጋግጧል",
|
||||
"heading": "ትዕዛዝ ተረጋግጧል",
|
||||
"body1": "አመሰግናለን {userName}። ትዕዛዝ {orderId}ን ተቀብለናል እና የፋርማሲ ቡድኑ እየገመገመው ነው።",
|
||||
"orderLabel": "ትዕዛዝ",
|
||||
"totalLabel": "ድምር",
|
||||
"cta": "ትዕዛዝን ተከታተል",
|
||||
"banner": "ማሸግ ሲጀምር በኢሜይል እናሳውቅዎታለን።"
|
||||
}
|
||||
},
|
||||
"order-packing": {
|
||||
"en": {
|
||||
"preview": "Order {orderId} is being packed",
|
||||
"heading": "Packing your order",
|
||||
"body1": "Hi {userName}, pharmacists at {branchName} are preparing order {orderId}.",
|
||||
"orderLabel": "Order",
|
||||
"totalLabel": "Total",
|
||||
"cta": "View status",
|
||||
"banner": "Status: Packing"
|
||||
},
|
||||
"am": {
|
||||
"preview": "ትዕዛዝ {orderId} እየታሸገ ነው",
|
||||
"heading": "ትዕዛዝዎን እያሸግን ነው",
|
||||
"body1": "ሰላም {userName}፣ በ{branchName} ያሉ ፋርማሲስቶች ትዕዛዝ {orderId}ን እያዘጋጁ ነው።",
|
||||
"orderLabel": "ትዕዛዝ",
|
||||
"totalLabel": "ድምር",
|
||||
"cta": "ሁኔታ ይመልከቱ",
|
||||
"banner": "ሁኔታ፡ እየታሸገ"
|
||||
}
|
||||
},
|
||||
"order-out-for-delivery": {
|
||||
"en": {
|
||||
"preview": "Order {orderId} is out for delivery",
|
||||
"heading": "Out for delivery",
|
||||
"body1": "Good news {userName}: order {orderId} left {branchName} with rider {riderName}.",
|
||||
"orderLabel": "Order",
|
||||
"totalLabel": "Total",
|
||||
"cta": "Track delivery",
|
||||
"banner": "Estimated arrival: {eta}"
|
||||
},
|
||||
"am": {
|
||||
"preview": "ትዕዛዝ {orderId} ለማድረስ በመንገድ ላይ ነው",
|
||||
"heading": "ለማድረስ በመንገድ ላይ",
|
||||
"body1": "ደስ የሚል ዜና {userName}፡ ትዕዛዝ {orderId} ከ{branchName} ከአድራሽ {riderName} ጋር ወጥቷል።",
|
||||
"orderLabel": "ትዕዛዝ",
|
||||
"totalLabel": "ድምር",
|
||||
"cta": "ማድረስን ተከታተል",
|
||||
"banner": "የሚገመት መድረሻ፡ {eta}"
|
||||
}
|
||||
},
|
||||
"order-delivered": {
|
||||
"en": {
|
||||
"preview": "Order {orderId} delivered",
|
||||
"heading": "Delivered",
|
||||
"body1": "Order {orderId} was delivered to {userName}. Thank you for choosing Gishen Pharmacy.",
|
||||
"orderLabel": "Order",
|
||||
"totalLabel": "Total",
|
||||
"cta": "Reorder",
|
||||
"banner": "Delivered successfully"
|
||||
},
|
||||
"am": {
|
||||
"preview": "ትዕዛዝ {orderId} ተደርሷል",
|
||||
"heading": "ተደርሷል",
|
||||
"body1": "ትዕዛዝ {orderId} ለ{userName} ተደርሷል። ጊሼን ፋርማሲን ስለመረጡ እናመሰግናለን።",
|
||||
"orderLabel": "ትዕዛዝ",
|
||||
"totalLabel": "ድምር",
|
||||
"cta": "ድጋሚ እዘዝ",
|
||||
"banner": "በተሳካ ሁኔታ ተደርሷል"
|
||||
}
|
||||
},
|
||||
"order-cancelled": {
|
||||
"en": {
|
||||
"preview": "Order {orderId} cancelled",
|
||||
"heading": "Order cancelled",
|
||||
"body1": "Hi {userName}, order {orderId} was cancelled. Reason: {reason}.",
|
||||
"orderLabel": "Order",
|
||||
"totalLabel": "Total",
|
||||
"cta": "Browse shop",
|
||||
"banner": "No payment was captured for this order."
|
||||
},
|
||||
"am": {
|
||||
"preview": "ትዕዛዝ {orderId} ተሰርዟል",
|
||||
"heading": "ትዕዛዝ ተሰርዟል",
|
||||
"body1": "ሰላም {userName}፣ ትዕዛዝ {orderId} ተሰርዟል። ምክንያት፡ {reason}።",
|
||||
"orderLabel": "ትዕዛዝ",
|
||||
"totalLabel": "ድምር",
|
||||
"cta": "ሱቁን ይመልከቱ",
|
||||
"banner": "ለዚህ ትዕዛዝ ክፍያ አልተያዘም።"
|
||||
}
|
||||
},
|
||||
"org-invite": {
|
||||
"en": {
|
||||
"preview": "Invite to {orgName} on Gishen B2B",
|
||||
"heading": "Organisation invitation",
|
||||
"body1": "{inviterName} invited you to join {orgName} on the Gishen B2B portal as {role}.",
|
||||
"cta": "Join organisation",
|
||||
"body2": "You’ll be able to manage members, packages, and covered pharmacy spend."
|
||||
},
|
||||
"am": {
|
||||
"preview": "በጊሼን B2B ላይ ወደ {orgName} ግብዣ",
|
||||
"heading": "የድርጅት ግብዣ",
|
||||
"body1": "{inviterName} እንደ {role} በጊሼን B2B ፖርታል ላይ {orgName}ን እንዲቀላቀሉ ጋብዘዎታል።",
|
||||
"cta": "ድርጅትን ይቀላቀሉ",
|
||||
"body2": "አባላትን፣ ጥቅሎችን እና የተሸፈነ የፋርማሲ ወጪን ማስተዳደር ይችላሉ።"
|
||||
}
|
||||
},
|
||||
"join-code": {
|
||||
"en": {
|
||||
"preview": "Your Gishen join code",
|
||||
"heading": "Join with this code",
|
||||
"body1": "Hi {userName}, use this code to join {orgName} on Gishen B2B.",
|
||||
"cta": "Enter join code",
|
||||
"banner": "Code: {joinCode}"
|
||||
},
|
||||
"am": {
|
||||
"preview": "የጊሼን የመቀላቀያ ኮድዎ",
|
||||
"heading": "በዚህ ኮድ ይቀላቀሉ",
|
||||
"body1": "ሰላም {userName}፣ በጊሼን B2B ላይ {orgName}ን ለመቀላቀል ይህን ኮድ ይጠቀሙ።",
|
||||
"cta": "የመቀላቀያ ኮድ አስገባ",
|
||||
"banner": "ኮድ፡ {joinCode}"
|
||||
}
|
||||
},
|
||||
"approval-needed": {
|
||||
"en": {
|
||||
"preview": "Approval needed for {requestType}",
|
||||
"heading": "Action required",
|
||||
"body1": "Hi {approverName}, {requesterName} submitted a {requestType} that needs your approval for {orgName}.",
|
||||
"cta": "Review request",
|
||||
"banner": "Pending finance / HR approval"
|
||||
},
|
||||
"am": {
|
||||
"preview": "ለ{requestType} ማጽደቅ ያስፈልጋል",
|
||||
"heading": "እርምጃ ያስፈልጋል",
|
||||
"body1": "ሰላም {approverName}፣ {requesterName} ለ{orgName} ማጽደቅ የሚያስፈልገውን {requestType} አስገብቷል።",
|
||||
"cta": "ጥያቄን ይገምግሙ",
|
||||
"banner": "የፋይናንስ / HR ማጽደቅ በመጠባበቅ ላይ"
|
||||
}
|
||||
},
|
||||
"statement-ready": {
|
||||
"en": {
|
||||
"preview": "Statement ready for {periodLabel}",
|
||||
"heading": "Statement ready",
|
||||
"body1": "Hi {userName}, the pharmacy benefits statement for {orgName} ({periodLabel}) is ready.",
|
||||
"cta": "Download statement",
|
||||
"banner": "Amount due: {amountDue}"
|
||||
},
|
||||
"am": {
|
||||
"preview": "ለ{periodLabel} መግለጫ ዝግጁ ነው",
|
||||
"heading": "መግለጫ ዝግጁ ነው",
|
||||
"body1": "ሰላም {userName}፣ የ{orgName} የፋርማሲ ጥቅማጥቅም መግለጫ ({periodLabel}) ዝግጁ ነው።",
|
||||
"cta": "መግለጫ አውርድ",
|
||||
"banner": "የሚከፈል መጠን፡ {amountDue}"
|
||||
}
|
||||
},
|
||||
"credit-alert": {
|
||||
"en": {
|
||||
"preview": "Credit alert for {orgName}",
|
||||
"heading": "Credit utilisation alert",
|
||||
"body1": "Hi {userName}, {orgName} has used {usedPercent}% of its pharmacy credit limit.",
|
||||
"banner": "Available credit: {availableCredit}",
|
||||
"cta": "View finance dashboard",
|
||||
"body2": "Contact your Gishen account manager if you need a limit review."
|
||||
},
|
||||
"am": {
|
||||
"preview": "ለ{orgName} የክሬዲት ማስጠንቀቂያ",
|
||||
"heading": "የክሬዲት አጠቃቀም ማስጠንቀቂያ",
|
||||
"body1": "ሰላም {userName}፣ {orgName} የፋርማሲ ክሬዲት ገደቡን {usedPercent}% ተጠቅሟል።",
|
||||
"banner": "ያለ ክሬዲት፡ {availableCredit}",
|
||||
"cta": "የፋይናንስ ዳሽቦርድ ይመልከቱ",
|
||||
"body2": "የገደብ ግምገማ ካስፈለገዎት የጊሼን አካውንት ማኔጀርዎን ያግኙ።"
|
||||
}
|
||||
},
|
||||
"organisation-verified": {
|
||||
"en": {
|
||||
"preview": "{orgName} is verified",
|
||||
"heading": "Organisation verified",
|
||||
"body1": "Great news {userName}: {orgName} passed Gishen verification and can now use B2B benefits features.",
|
||||
"cta": "Open B2B portal",
|
||||
"banner": "Verification complete"
|
||||
},
|
||||
"am": {
|
||||
"preview": "{orgName} ተረጋግጧል",
|
||||
"heading": "ድርጅት ተረጋግጧል",
|
||||
"body1": "ደስ የሚል ዜና {userName}፡ {orgName} የጊሼን ማረጋገጫ አልፏል እና አሁን የB2B ጥቅማጥቅም ባህሪያትን መጠቀም ይችላል።",
|
||||
"cta": "B2B ፖርታል ክፈት",
|
||||
"banner": "ማረጋገጫ ተጠናቋል"
|
||||
}
|
||||
},
|
||||
"prescription-submitted": {
|
||||
"en": {
|
||||
"preview": "Prescription {rxId} received",
|
||||
"heading": "Prescription submitted",
|
||||
"body1": "Hi {userName}, we received prescription {rxId}. A pharmacist will review it shortly.",
|
||||
"cta": "View prescription",
|
||||
"banner": "Under clinical review"
|
||||
},
|
||||
"am": {
|
||||
"preview": "የመድሃኒት ማዘዣ {rxId} ደርሷል",
|
||||
"heading": "የመድሃኒት ማዘዣ ገብቷል",
|
||||
"body1": "ሰላም {userName}፣ የመድሃኒት ማዘዣ {rxId}ን ተቀብለናል። ፋርማሲስት በቅርቡ ይገመግመዋል።",
|
||||
"cta": "ማዘዣ ይመልከቱ",
|
||||
"banner": "በክሊኒካል ግምገማ ላይ"
|
||||
}
|
||||
},
|
||||
"prescription-decision": {
|
||||
"en": {
|
||||
"preview": "Prescription {rxId} {decision}",
|
||||
"heading": "Prescription {decision}",
|
||||
"body1": "Hi {userName}, prescription {rxId} was {decision}. {note}",
|
||||
"cta": "Open prescription",
|
||||
"approvedBanner": "Approved — you can proceed to order",
|
||||
"rejectedBanner": "Rejected — see pharmacist note"
|
||||
},
|
||||
"am": {
|
||||
"preview": "የመድሃኒት ማዘዣ {rxId} {decision}",
|
||||
"heading": "የመድሃኒት ማዘዣ {decision}",
|
||||
"body1": "ሰላም {userName}፣ የመድሃኒት ማዘዣ {rxId} {decision} ሆኗል። {note}",
|
||||
"cta": "ማዘዣ ክፈት",
|
||||
"approvedBanner": "ጸድቋል — ለመግዛት መቀጠል ይችላሉ",
|
||||
"rejectedBanner": "ተቀባይነት አላገኘም — የፋርማሲስት ማስታወሻን ይመልከቱ"
|
||||
}
|
||||
},
|
||||
"prescription-ready": {
|
||||
"en": {
|
||||
"preview": "Prescription {rxId} ready for pickup",
|
||||
"heading": "Ready for pickup",
|
||||
"body1": "Hi {userName}, prescription {rxId} is ready at {branchName}.",
|
||||
"cta": "Get directions",
|
||||
"banner": "Bring a valid ID when you collect."
|
||||
},
|
||||
"am": {
|
||||
"preview": "የመድሃኒት ማዘዣ {rxId} ለመውሰድ ዝግጁ ነው",
|
||||
"heading": "ለመውሰድ ዝግጁ",
|
||||
"body1": "ሰላም {userName}፣ የመድሃኒት ማዘዣ {rxId} በ{branchName} ዝግጁ ነው።",
|
||||
"cta": "አቅጣጫ ያግኙ",
|
||||
"banner": "ሲወስዱ የሚሰራ መታወቂያ ይዘው ይምጡ።"
|
||||
}
|
||||
},
|
||||
"low-stock": {
|
||||
"en": {
|
||||
"preview": "Low stock: {skuName}",
|
||||
"heading": "Low stock alert",
|
||||
"body1": "SKU {skuCode} ({skuName}) at {branchName} is down to {quantity} units (threshold {threshold}).",
|
||||
"cta": "Open stock",
|
||||
"banner": "Action needed in Admin"
|
||||
},
|
||||
"am": {
|
||||
"preview": "አነስተኛ ክምችት፡ {skuName}",
|
||||
"heading": "የአነስተኛ ክምችት ማስጠንቀቂያ",
|
||||
"body1": "SKU {skuCode} ({skuName}) በ{branchName} ወደ {quantity} አሃዶች ዝቅ ብሏል (ገደብ {threshold})።",
|
||||
"cta": "ክምችት ክፈት",
|
||||
"banner": "በአስተዳደር ውስጥ እርምጃ ያስፈልጋል"
|
||||
}
|
||||
},
|
||||
"dispatch-assigned": {
|
||||
"en": {
|
||||
"preview": "Dispatch assigned for {orderId}",
|
||||
"heading": "Dispatch assigned",
|
||||
"body1": "Order {orderId} was assigned to rider {riderName} from {branchName}.",
|
||||
"cta": "Open dispatch",
|
||||
"banner": "Pickup window: {pickupWindow}"
|
||||
},
|
||||
"am": {
|
||||
"preview": "ለ{orderId} ማጓጓዝ ተመድቧል",
|
||||
"heading": "ማጓጓዝ ተመድቧል",
|
||||
"body1": "ትዕዛዝ {orderId} ከ{branchName} ለአድራሽ {riderName} ተመድቧል።",
|
||||
"cta": "ማጓጓዝ ክፈት",
|
||||
"banner": "የመውሰጃ ጊዜ፡ {pickupWindow}"
|
||||
}
|
||||
},
|
||||
"procurement-alert": {
|
||||
"en": {
|
||||
"preview": "Procurement alert: {title}",
|
||||
"heading": "Procurement alert",
|
||||
"body1": "Hi {userName}, {title}. Supplier: {supplierName}. Priority: {priority}.",
|
||||
"cta": "Review procurement",
|
||||
"banner": "Ops notification"
|
||||
},
|
||||
"am": {
|
||||
"preview": "የግዥ ማስጠንቀቂያ፡ {title}",
|
||||
"heading": "የግዥ ማስጠንቀቂያ",
|
||||
"body1": "ሰላም {userName}፣ {title}። አቅራቢ፡ {supplierName}። ቅድሚያ፡ {priority}።",
|
||||
"cta": "ግዥን ይገምግሙ",
|
||||
"banner": "የኦፕስ ማሳወቂያ"
|
||||
}
|
||||
},
|
||||
"promotional": {
|
||||
"en": {
|
||||
"preview": "{headline}",
|
||||
"heading": "{headline}",
|
||||
"body1": "{body}",
|
||||
"cta": "{cta}",
|
||||
"banner": "From Gishen Pharmacy"
|
||||
},
|
||||
"am": {
|
||||
"preview": "{headline}",
|
||||
"heading": "{headline}",
|
||||
"body1": "{body}",
|
||||
"cta": "{cta}",
|
||||
"banner": "ከጊሼን ፋርማሲ"
|
||||
}
|
||||
},
|
||||
"shop-summary": {
|
||||
"en": {
|
||||
"preview": "{periodLabel} shop summary",
|
||||
"heading": "{periodLabel} e‑commerce summary",
|
||||
"body1": "Hi {userName}, here is the Gishen shop performance for {periodLabel} ({dateRange}).",
|
||||
"cta": "Open analytics",
|
||||
"orders": "Orders",
|
||||
"revenue": "Revenue",
|
||||
"aov": "Avg order",
|
||||
"topSku": "Top SKU"
|
||||
},
|
||||
"am": {
|
||||
"preview": "የ{periodLabel} የሱቅ ማጠቃለያ",
|
||||
"heading": "የ{periodLabel} ኢ-ኮሜርስ ማጠቃለያ",
|
||||
"body1": "ሰላም {userName}፣ ለ{periodLabel} ({dateRange}) የጊሼን ሱቅ አፈጻጸም ይኸውና።",
|
||||
"cta": "ትንታኔ ክፈት",
|
||||
"orders": "ትዕዛዞች",
|
||||
"revenue": "ገቢ",
|
||||
"aov": "አማካይ ትዕዛዝ",
|
||||
"topSku": "ከፍተኛ SKU"
|
||||
}
|
||||
}
|
||||
} as const;
|
||||
|
||||
export type TemplateId = keyof typeof templateCopy;
|
||||
|
||||
export function tTemplate<T extends TemplateId>(id: T, locale: Locale) {
|
||||
return templateCopy[id][locale];
|
||||
}
|
||||
5
emails/i18n/types.ts
Normal file
5
emails/i18n/types.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export type Locale = 'en' | 'am';
|
||||
|
||||
export function resolveLocale(locale?: string): Locale {
|
||||
return locale === 'am' ? 'am' : 'en';
|
||||
}
|
||||
44
emails/join-code.tsx
Normal file
44
emails/join-code.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { Heading, Section, Text } from '@react-email/components';
|
||||
import { EmailLayout } from './components/EmailLayout';
|
||||
import { Button } from './components/Button';
|
||||
import { StatusBanner } from './components/StatusBanner';
|
||||
import { styles } from './components/styles';
|
||||
import { tCommon } from './i18n/common';
|
||||
import { tTemplate } from './i18n/templates';
|
||||
import { resolveLocale, type Locale } from './i18n/types';
|
||||
import { interpolate } from './utils/interpolate';
|
||||
import { theme } from './theme';
|
||||
|
||||
export interface JoinCodeEmailProps {
|
||||
locale?: Locale;
|
||||
userName?: string;
|
||||
orgName?: string;
|
||||
joinCode?: string;
|
||||
ctaUrl?: string;
|
||||
}
|
||||
|
||||
export const JoinCodeEmail = ({
|
||||
locale,
|
||||
userName = 'Abebe Kebede',
|
||||
orgName = 'Yaltopia Tech',
|
||||
joinCode = 'GISHEN-YT-7942',
|
||||
ctaUrl = `${theme.brand.website}/b2b/join`,
|
||||
}: JoinCodeEmailProps) => {
|
||||
const l = resolveLocale(locale);
|
||||
const t = tTemplate('join-code', l);
|
||||
const common = tCommon(l);
|
||||
return (
|
||||
<EmailLayout preview={t.preview} locale={l}>
|
||||
<StatusBanner tone="brand">{interpolate(t.banner, { joinCode })}</StatusBanner>
|
||||
<Heading style={styles.heading}>{t.heading}</Heading>
|
||||
<Text style={styles.text}>{interpolate(t.body1, { userName, orgName })}</Text>
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href={ctaUrl}>{t.cta}</Button>
|
||||
</Section>
|
||||
<Text style={styles.muted}>{common.regards}<br />{common.team}</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
JoinCodeEmail.PreviewProps = { locale: 'en' } as JoinCodeEmailProps;
|
||||
export default JoinCodeEmail;
|
||||
48
emails/low-stock.tsx
Normal file
48
emails/low-stock.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { Heading, Section, Text } from '@react-email/components';
|
||||
import { EmailLayout } from './components/EmailLayout';
|
||||
import { Button } from './components/Button';
|
||||
import { StatusBanner } from './components/StatusBanner';
|
||||
import { styles } from './components/styles';
|
||||
import { tCommon } from './i18n/common';
|
||||
import { tTemplate } from './i18n/templates';
|
||||
import { resolveLocale, type Locale } from './i18n/types';
|
||||
import { interpolate } from './utils/interpolate';
|
||||
import { theme } from './theme';
|
||||
|
||||
export interface LowStockEmailProps {
|
||||
locale?: Locale;
|
||||
skuCode?: string;
|
||||
skuName?: string;
|
||||
branchName?: string;
|
||||
quantity?: string;
|
||||
threshold?: string;
|
||||
ctaUrl?: string;
|
||||
}
|
||||
|
||||
export const LowStockEmail = ({
|
||||
locale,
|
||||
skuCode = 'MED-AMOX-500',
|
||||
skuName = 'Amoxicillin 500mg',
|
||||
branchName = 'Bole Branch',
|
||||
quantity = '8',
|
||||
threshold = '25',
|
||||
ctaUrl = `${theme.brand.website}/admin/stock`,
|
||||
}: LowStockEmailProps) => {
|
||||
const l = resolveLocale(locale);
|
||||
const t = tTemplate('low-stock', l);
|
||||
const common = tCommon(l);
|
||||
return (
|
||||
<EmailLayout preview={interpolate(t.preview, { skuName })} locale={l}>
|
||||
<StatusBanner tone="warning">{t.banner}</StatusBanner>
|
||||
<Heading style={styles.heading}>{t.heading}</Heading>
|
||||
<Text style={styles.text}>{interpolate(t.body1, { skuCode, skuName, branchName, quantity, threshold })}</Text>
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href={ctaUrl} variant="accent">{t.cta}</Button>
|
||||
</Section>
|
||||
<Text style={styles.muted}>{common.regards}<br />{common.team}</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
LowStockEmail.PreviewProps = { locale: 'en' } as LowStockEmailProps;
|
||||
export default LowStockEmail;
|
||||
59
emails/order-cancelled.tsx
Normal file
59
emails/order-cancelled.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { Heading, Section, Text } from '@react-email/components';
|
||||
import { EmailLayout } from './components/EmailLayout';
|
||||
import { Button } from './components/Button';
|
||||
import { StatusBanner } from './components/StatusBanner';
|
||||
import { OrderSummary, type OrderLineItem } from './components/OrderSummary';
|
||||
import { styles } from './components/styles';
|
||||
import { tCommon } from './i18n/common';
|
||||
import { tTemplate } from './i18n/templates';
|
||||
import { resolveLocale, type Locale } from './i18n/types';
|
||||
import { interpolate } from './utils/interpolate';
|
||||
import { theme } from './theme';
|
||||
|
||||
export interface OrderCancelledEmailProps {
|
||||
locale?: Locale;
|
||||
userName?: string;
|
||||
orderId?: string;
|
||||
items?: OrderLineItem[];
|
||||
total?: string;
|
||||
ctaUrl?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export const OrderCancelledEmail = ({
|
||||
locale,
|
||||
userName = 'Abebe Kebede',
|
||||
orderId = 'GS-10482',
|
||||
items = [
|
||||
{ name: 'Amoxicillin 500mg', qty: 2, price: 'ETB 240' },
|
||||
{ name: 'ORS sachets', qty: 5, price: 'ETB 125' },
|
||||
],
|
||||
total = 'ETB 365',
|
||||
ctaUrl = `${theme.brand.website}/orders/${orderId}`,
|
||||
reason = 'Item out of stock',
|
||||
}: OrderCancelledEmailProps) => {
|
||||
const l = resolveLocale(locale);
|
||||
const t = tTemplate('order-cancelled', l);
|
||||
const common = tCommon(l);
|
||||
return (
|
||||
<EmailLayout preview={interpolate(t.preview, { orderId })} locale={l}>
|
||||
<StatusBanner tone="danger">{t.banner}</StatusBanner>
|
||||
<Heading style={styles.heading}>{t.heading}</Heading>
|
||||
<Text style={styles.text}>{interpolate(t.body1, { userName, orderId, reason })}</Text>
|
||||
<OrderSummary
|
||||
orderId={orderId}
|
||||
items={items}
|
||||
total={total}
|
||||
orderLabel={t.orderLabel}
|
||||
totalLabel={t.totalLabel}
|
||||
/>
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href={ctaUrl}>{t.cta}</Button>
|
||||
</Section>
|
||||
<Text style={styles.muted}>{common.regards}<br />{common.team}</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
OrderCancelledEmail.PreviewProps = { locale: 'en' } as OrderCancelledEmailProps;
|
||||
export default OrderCancelledEmail;
|
||||
59
emails/order-confirmation.tsx
Normal file
59
emails/order-confirmation.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { Heading, Section, Text } from '@react-email/components';
|
||||
import { EmailLayout } from './components/EmailLayout';
|
||||
import { Button } from './components/Button';
|
||||
import { StatusBanner } from './components/StatusBanner';
|
||||
import { OrderSummary, type OrderLineItem } from './components/OrderSummary';
|
||||
import { styles } from './components/styles';
|
||||
import { tCommon } from './i18n/common';
|
||||
import { tTemplate } from './i18n/templates';
|
||||
import { resolveLocale, type Locale } from './i18n/types';
|
||||
import { interpolate } from './utils/interpolate';
|
||||
import { theme } from './theme';
|
||||
|
||||
export interface OrderConfirmationEmailProps {
|
||||
locale?: Locale;
|
||||
userName?: string;
|
||||
orderId?: string;
|
||||
items?: OrderLineItem[];
|
||||
total?: string;
|
||||
ctaUrl?: string;
|
||||
|
||||
}
|
||||
|
||||
export const OrderConfirmationEmail = ({
|
||||
locale,
|
||||
userName = 'Abebe Kebede',
|
||||
orderId = 'GS-10482',
|
||||
items = [
|
||||
{ name: 'Amoxicillin 500mg', qty: 2, price: 'ETB 240' },
|
||||
{ name: 'ORS sachets', qty: 5, price: 'ETB 125' },
|
||||
],
|
||||
total = 'ETB 365',
|
||||
ctaUrl = `${theme.brand.website}/orders/${orderId}`,
|
||||
|
||||
}: OrderConfirmationEmailProps) => {
|
||||
const l = resolveLocale(locale);
|
||||
const t = tTemplate('order-confirmation', l);
|
||||
const common = tCommon(l);
|
||||
return (
|
||||
<EmailLayout preview={interpolate(t.preview, { orderId })} locale={l}>
|
||||
<StatusBanner tone="brand">{t.banner}</StatusBanner>
|
||||
<Heading style={styles.heading}>{t.heading}</Heading>
|
||||
<Text style={styles.text}>{interpolate(t.body1, { userName, orderId })}</Text>
|
||||
<OrderSummary
|
||||
orderId={orderId}
|
||||
items={items}
|
||||
total={total}
|
||||
orderLabel={t.orderLabel}
|
||||
totalLabel={t.totalLabel}
|
||||
/>
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href={ctaUrl}>{t.cta}</Button>
|
||||
</Section>
|
||||
<Text style={styles.muted}>{common.regards}<br />{common.team}</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
OrderConfirmationEmail.PreviewProps = { locale: 'en' } as OrderConfirmationEmailProps;
|
||||
export default OrderConfirmationEmail;
|
||||
59
emails/order-delivered.tsx
Normal file
59
emails/order-delivered.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { Heading, Section, Text } from '@react-email/components';
|
||||
import { EmailLayout } from './components/EmailLayout';
|
||||
import { Button } from './components/Button';
|
||||
import { StatusBanner } from './components/StatusBanner';
|
||||
import { OrderSummary, type OrderLineItem } from './components/OrderSummary';
|
||||
import { styles } from './components/styles';
|
||||
import { tCommon } from './i18n/common';
|
||||
import { tTemplate } from './i18n/templates';
|
||||
import { resolveLocale, type Locale } from './i18n/types';
|
||||
import { interpolate } from './utils/interpolate';
|
||||
import { theme } from './theme';
|
||||
|
||||
export interface OrderDeliveredEmailProps {
|
||||
locale?: Locale;
|
||||
userName?: string;
|
||||
orderId?: string;
|
||||
items?: OrderLineItem[];
|
||||
total?: string;
|
||||
ctaUrl?: string;
|
||||
|
||||
}
|
||||
|
||||
export const OrderDeliveredEmail = ({
|
||||
locale,
|
||||
userName = 'Abebe Kebede',
|
||||
orderId = 'GS-10482',
|
||||
items = [
|
||||
{ name: 'Amoxicillin 500mg', qty: 2, price: 'ETB 240' },
|
||||
{ name: 'ORS sachets', qty: 5, price: 'ETB 125' },
|
||||
],
|
||||
total = 'ETB 365',
|
||||
ctaUrl = `${theme.brand.website}/orders/${orderId}`,
|
||||
|
||||
}: OrderDeliveredEmailProps) => {
|
||||
const l = resolveLocale(locale);
|
||||
const t = tTemplate('order-delivered', l);
|
||||
const common = tCommon(l);
|
||||
return (
|
||||
<EmailLayout preview={interpolate(t.preview, { orderId })} locale={l}>
|
||||
<StatusBanner tone="success">{t.banner}</StatusBanner>
|
||||
<Heading style={styles.heading}>{t.heading}</Heading>
|
||||
<Text style={styles.text}>{interpolate(t.body1, { userName, orderId })}</Text>
|
||||
<OrderSummary
|
||||
orderId={orderId}
|
||||
items={items}
|
||||
total={total}
|
||||
orderLabel={t.orderLabel}
|
||||
totalLabel={t.totalLabel}
|
||||
/>
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href={ctaUrl}>{t.cta}</Button>
|
||||
</Section>
|
||||
<Text style={styles.muted}>{common.regards}<br />{common.team}</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
OrderDeliveredEmail.PreviewProps = { locale: 'en' } as OrderDeliveredEmailProps;
|
||||
export default OrderDeliveredEmail;
|
||||
63
emails/order-out-for-delivery.tsx
Normal file
63
emails/order-out-for-delivery.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { Heading, Section, Text } from '@react-email/components';
|
||||
import { EmailLayout } from './components/EmailLayout';
|
||||
import { Button } from './components/Button';
|
||||
import { StatusBanner } from './components/StatusBanner';
|
||||
import { OrderSummary, type OrderLineItem } from './components/OrderSummary';
|
||||
import { styles } from './components/styles';
|
||||
import { tCommon } from './i18n/common';
|
||||
import { tTemplate } from './i18n/templates';
|
||||
import { resolveLocale, type Locale } from './i18n/types';
|
||||
import { interpolate } from './utils/interpolate';
|
||||
import { theme } from './theme';
|
||||
|
||||
export interface OrderOutForDeliveryEmailProps {
|
||||
locale?: Locale;
|
||||
userName?: string;
|
||||
orderId?: string;
|
||||
items?: OrderLineItem[];
|
||||
total?: string;
|
||||
ctaUrl?: string;
|
||||
branchName?: string;
|
||||
riderName?: string;
|
||||
eta?: string;
|
||||
}
|
||||
|
||||
export const OrderOutForDeliveryEmail = ({
|
||||
locale,
|
||||
userName = 'Abebe Kebede',
|
||||
orderId = 'GS-10482',
|
||||
items = [
|
||||
{ name: 'Amoxicillin 500mg', qty: 2, price: 'ETB 240' },
|
||||
{ name: 'ORS sachets', qty: 5, price: 'ETB 125' },
|
||||
],
|
||||
total = 'ETB 365',
|
||||
ctaUrl = `${theme.brand.website}/orders/${orderId}`,
|
||||
branchName = 'Bole Branch',
|
||||
riderName = 'Dawit Lemma',
|
||||
eta = '45 min',
|
||||
}: OrderOutForDeliveryEmailProps) => {
|
||||
const l = resolveLocale(locale);
|
||||
const t = tTemplate('order-out-for-delivery', l);
|
||||
const common = tCommon(l);
|
||||
return (
|
||||
<EmailLayout preview={interpolate(t.preview, { orderId })} locale={l}>
|
||||
<StatusBanner tone="brand">{interpolate(t.banner, { eta })}</StatusBanner>
|
||||
<Heading style={styles.heading}>{t.heading}</Heading>
|
||||
<Text style={styles.text}>{interpolate(t.body1, { userName, branchName, riderName, orderId })}</Text>
|
||||
<OrderSummary
|
||||
orderId={orderId}
|
||||
items={items}
|
||||
total={total}
|
||||
orderLabel={t.orderLabel}
|
||||
totalLabel={t.totalLabel}
|
||||
/>
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href={ctaUrl}>{t.cta}</Button>
|
||||
</Section>
|
||||
<Text style={styles.muted}>{common.regards}<br />{common.team}</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
OrderOutForDeliveryEmail.PreviewProps = { locale: 'en' } as OrderOutForDeliveryEmailProps;
|
||||
export default OrderOutForDeliveryEmail;
|
||||
59
emails/order-packing.tsx
Normal file
59
emails/order-packing.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { Heading, Section, Text } from '@react-email/components';
|
||||
import { EmailLayout } from './components/EmailLayout';
|
||||
import { Button } from './components/Button';
|
||||
import { StatusBanner } from './components/StatusBanner';
|
||||
import { OrderSummary, type OrderLineItem } from './components/OrderSummary';
|
||||
import { styles } from './components/styles';
|
||||
import { tCommon } from './i18n/common';
|
||||
import { tTemplate } from './i18n/templates';
|
||||
import { resolveLocale, type Locale } from './i18n/types';
|
||||
import { interpolate } from './utils/interpolate';
|
||||
import { theme } from './theme';
|
||||
|
||||
export interface OrderPackingEmailProps {
|
||||
locale?: Locale;
|
||||
userName?: string;
|
||||
orderId?: string;
|
||||
items?: OrderLineItem[];
|
||||
total?: string;
|
||||
ctaUrl?: string;
|
||||
branchName?: string;
|
||||
}
|
||||
|
||||
export const OrderPackingEmail = ({
|
||||
locale,
|
||||
userName = 'Abebe Kebede',
|
||||
orderId = 'GS-10482',
|
||||
items = [
|
||||
{ name: 'Amoxicillin 500mg', qty: 2, price: 'ETB 240' },
|
||||
{ name: 'ORS sachets', qty: 5, price: 'ETB 125' },
|
||||
],
|
||||
total = 'ETB 365',
|
||||
ctaUrl = `${theme.brand.website}/orders/${orderId}`,
|
||||
branchName = 'Bole Branch',
|
||||
}: OrderPackingEmailProps) => {
|
||||
const l = resolveLocale(locale);
|
||||
const t = tTemplate('order-packing', l);
|
||||
const common = tCommon(l);
|
||||
return (
|
||||
<EmailLayout preview={interpolate(t.preview, { orderId })} locale={l}>
|
||||
<StatusBanner tone="brand">{t.banner}</StatusBanner>
|
||||
<Heading style={styles.heading}>{t.heading}</Heading>
|
||||
<Text style={styles.text}>{interpolate(t.body1, { userName, branchName, orderId })}</Text>
|
||||
<OrderSummary
|
||||
orderId={orderId}
|
||||
items={items}
|
||||
total={total}
|
||||
orderLabel={t.orderLabel}
|
||||
totalLabel={t.totalLabel}
|
||||
/>
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href={ctaUrl}>{t.cta}</Button>
|
||||
</Section>
|
||||
<Text style={styles.muted}>{common.regards}<br />{common.team}</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
OrderPackingEmail.PreviewProps = { locale: 'en' } as OrderPackingEmailProps;
|
||||
export default OrderPackingEmail;
|
||||
43
emails/org-invite.tsx
Normal file
43
emails/org-invite.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { Heading, Section, Text } from '@react-email/components';
|
||||
import { EmailLayout } from './components/EmailLayout';
|
||||
import { Button } from './components/Button';
|
||||
import { styles } from './components/styles';
|
||||
import { tCommon } from './i18n/common';
|
||||
import { tTemplate } from './i18n/templates';
|
||||
import { resolveLocale, type Locale } from './i18n/types';
|
||||
import { interpolate } from './utils/interpolate';
|
||||
import { theme } from './theme';
|
||||
|
||||
export interface OrgInviteEmailProps {
|
||||
locale?: Locale;
|
||||
orgName?: string;
|
||||
inviterName?: string;
|
||||
role?: string;
|
||||
inviteUrl?: string;
|
||||
}
|
||||
|
||||
export const OrgInviteEmail = ({
|
||||
locale,
|
||||
orgName = 'Yaltopia Tech',
|
||||
inviterName = 'Helen Tadesse',
|
||||
role = 'HR Admin',
|
||||
inviteUrl = `${theme.brand.website}/b2b/invite`,
|
||||
}: OrgInviteEmailProps) => {
|
||||
const l = resolveLocale(locale);
|
||||
const t = tTemplate('org-invite', l);
|
||||
const common = tCommon(l);
|
||||
return (
|
||||
<EmailLayout preview={interpolate(t.preview, { orgName })} locale={l}>
|
||||
<Heading style={styles.heading}>{t.heading}</Heading>
|
||||
<Text style={styles.text}>{interpolate(t.body1, { inviterName, orgName, role })}</Text>
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href={inviteUrl}>{t.cta}</Button>
|
||||
</Section>
|
||||
<Text style={styles.muted}>{t.body2}</Text>
|
||||
<Text style={styles.muted}>{common.regards}<br />{common.team}</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
OrgInviteEmail.PreviewProps = { locale: 'en' } as OrgInviteEmailProps;
|
||||
export default OrgInviteEmail;
|
||||
42
emails/organisation-verified.tsx
Normal file
42
emails/organisation-verified.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { Heading, Section, Text } from '@react-email/components';
|
||||
import { EmailLayout } from './components/EmailLayout';
|
||||
import { Button } from './components/Button';
|
||||
import { StatusBanner } from './components/StatusBanner';
|
||||
import { styles } from './components/styles';
|
||||
import { tCommon } from './i18n/common';
|
||||
import { tTemplate } from './i18n/templates';
|
||||
import { resolveLocale, type Locale } from './i18n/types';
|
||||
import { interpolate } from './utils/interpolate';
|
||||
import { theme } from './theme';
|
||||
|
||||
export interface OrganisationVerifiedEmailProps {
|
||||
locale?: Locale;
|
||||
userName?: string;
|
||||
orgName?: string;
|
||||
ctaUrl?: string;
|
||||
}
|
||||
|
||||
export const OrganisationVerifiedEmail = ({
|
||||
locale,
|
||||
userName = 'Helen Tadesse',
|
||||
orgName = 'Yaltopia Tech',
|
||||
ctaUrl = `${theme.brand.website}/b2b`,
|
||||
}: OrganisationVerifiedEmailProps) => {
|
||||
const l = resolveLocale(locale);
|
||||
const t = tTemplate('organisation-verified', l);
|
||||
const common = tCommon(l);
|
||||
return (
|
||||
<EmailLayout preview={interpolate(t.preview, { orgName })} locale={l}>
|
||||
<StatusBanner tone="success">{t.banner}</StatusBanner>
|
||||
<Heading style={styles.heading}>{t.heading}</Heading>
|
||||
<Text style={styles.text}>{interpolate(t.body1, { userName, orgName })}</Text>
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href={ctaUrl}>{t.cta}</Button>
|
||||
</Section>
|
||||
<Text style={styles.muted}>{common.regards}<br />{common.team}</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
OrganisationVerifiedEmail.PreviewProps = { locale: 'en' } as OrganisationVerifiedEmailProps;
|
||||
export default OrganisationVerifiedEmail;
|
||||
41
emails/password-reset.tsx
Normal file
41
emails/password-reset.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { Heading, Section, Text } from '@react-email/components';
|
||||
import { EmailLayout } from './components/EmailLayout';
|
||||
import { Button } from './components/Button';
|
||||
import { StatusBanner } from './components/StatusBanner';
|
||||
import { styles } from './components/styles';
|
||||
import { tCommon } from './i18n/common';
|
||||
import { tTemplate } from './i18n/templates';
|
||||
import { resolveLocale, type Locale } from './i18n/types';
|
||||
import { interpolate } from './utils/interpolate';
|
||||
import { theme } from './theme';
|
||||
|
||||
export interface PasswordResetEmailProps {
|
||||
locale?: Locale;
|
||||
userName?: string;
|
||||
resetUrl?: string;
|
||||
}
|
||||
|
||||
export const PasswordResetEmail = ({
|
||||
locale,
|
||||
userName = 'Abebe Kebede',
|
||||
resetUrl = `${theme.brand.website}/reset-password`,
|
||||
}: PasswordResetEmailProps) => {
|
||||
const l = resolveLocale(locale);
|
||||
const t = tTemplate('password-reset', l);
|
||||
const common = tCommon(l);
|
||||
return (
|
||||
<EmailLayout preview={t.preview} locale={l}>
|
||||
<StatusBanner tone="warning">{t.banner}</StatusBanner>
|
||||
<Heading style={styles.heading}>{t.heading}</Heading>
|
||||
<Text style={styles.text}>{interpolate(t.body1, { userName })}</Text>
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href={resetUrl} variant="accent">{t.cta}</Button>
|
||||
</Section>
|
||||
<Text style={styles.muted}>{t.body2}</Text>
|
||||
<Text style={styles.muted}>{common.regards}<br />{common.team}</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
PasswordResetEmail.PreviewProps = { userName: 'Abebe Kebede', locale: 'en' } as PasswordResetEmailProps;
|
||||
export default PasswordResetEmail;
|
||||
50
emails/prescription-decision.tsx
Normal file
50
emails/prescription-decision.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { Heading, Section, Text } from '@react-email/components';
|
||||
import { EmailLayout } from './components/EmailLayout';
|
||||
import { Button } from './components/Button';
|
||||
import { StatusBanner } from './components/StatusBanner';
|
||||
import { styles } from './components/styles';
|
||||
import { tCommon } from './i18n/common';
|
||||
import { tTemplate } from './i18n/templates';
|
||||
import { resolveLocale, type Locale } from './i18n/types';
|
||||
import { interpolate } from './utils/interpolate';
|
||||
import { theme } from './theme';
|
||||
|
||||
export interface PrescriptionDecisionEmailProps {
|
||||
locale?: Locale;
|
||||
userName?: string;
|
||||
rxId?: string;
|
||||
decision?: 'approved' | 'rejected';
|
||||
note?: string;
|
||||
ctaUrl?: string;
|
||||
}
|
||||
|
||||
export const PrescriptionDecisionEmail = ({
|
||||
locale,
|
||||
userName = 'Abebe Kebede',
|
||||
rxId = 'RX-5521',
|
||||
decision = 'approved',
|
||||
note = 'All items are available at Bole Branch.',
|
||||
ctaUrl = `${theme.brand.website}/prescriptions/${rxId}`,
|
||||
}: PrescriptionDecisionEmailProps) => {
|
||||
const l = resolveLocale(locale);
|
||||
const t = tTemplate('prescription-decision', l);
|
||||
const common = tCommon(l);
|
||||
const decisionLabel = decision === 'approved'
|
||||
? (l === 'am' ? 'ጸድቋል' : 'approved')
|
||||
: (l === 'am' ? 'ተቀባይነት አላገኘም' : 'rejected');
|
||||
const banner = decision === 'approved' ? t.approvedBanner : t.rejectedBanner;
|
||||
return (
|
||||
<EmailLayout preview={interpolate(t.preview, { rxId, decision: decisionLabel })} locale={l}>
|
||||
<StatusBanner tone={decision === 'approved' ? 'success' : 'danger'}>{banner}</StatusBanner>
|
||||
<Heading style={styles.heading}>{interpolate(t.heading, { decision: decisionLabel })}</Heading>
|
||||
<Text style={styles.text}>{interpolate(t.body1, { userName, rxId, decision: decisionLabel, note })}</Text>
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href={ctaUrl}>{t.cta}</Button>
|
||||
</Section>
|
||||
<Text style={styles.muted}>{common.regards}<br />{common.team}</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
PrescriptionDecisionEmail.PreviewProps = { locale: 'en', decision: 'approved' } as PrescriptionDecisionEmailProps;
|
||||
export default PrescriptionDecisionEmail;
|
||||
44
emails/prescription-ready.tsx
Normal file
44
emails/prescription-ready.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { Heading, Section, Text } from '@react-email/components';
|
||||
import { EmailLayout } from './components/EmailLayout';
|
||||
import { Button } from './components/Button';
|
||||
import { StatusBanner } from './components/StatusBanner';
|
||||
import { styles } from './components/styles';
|
||||
import { tCommon } from './i18n/common';
|
||||
import { tTemplate } from './i18n/templates';
|
||||
import { resolveLocale, type Locale } from './i18n/types';
|
||||
import { interpolate } from './utils/interpolate';
|
||||
import { theme } from './theme';
|
||||
|
||||
export interface PrescriptionReadyEmailProps {
|
||||
locale?: Locale;
|
||||
userName?: string;
|
||||
rxId?: string;
|
||||
branchName?: string;
|
||||
ctaUrl?: string;
|
||||
}
|
||||
|
||||
export const PrescriptionReadyEmail = ({
|
||||
locale,
|
||||
userName = 'Abebe Kebede',
|
||||
rxId = 'RX-5521',
|
||||
branchName = 'Bole Branch',
|
||||
ctaUrl = `${theme.brand.website}/branches/bole`,
|
||||
}: PrescriptionReadyEmailProps) => {
|
||||
const l = resolveLocale(locale);
|
||||
const t = tTemplate('prescription-ready', l);
|
||||
const common = tCommon(l);
|
||||
return (
|
||||
<EmailLayout preview={interpolate(t.preview, { rxId })} locale={l}>
|
||||
<StatusBanner tone="success">{t.banner}</StatusBanner>
|
||||
<Heading style={styles.heading}>{t.heading}</Heading>
|
||||
<Text style={styles.text}>{interpolate(t.body1, { userName, rxId, branchName })}</Text>
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href={ctaUrl}>{t.cta}</Button>
|
||||
</Section>
|
||||
<Text style={styles.muted}>{common.regards}<br />{common.team}</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
PrescriptionReadyEmail.PreviewProps = { locale: 'en' } as PrescriptionReadyEmailProps;
|
||||
export default PrescriptionReadyEmail;
|
||||
42
emails/prescription-submitted.tsx
Normal file
42
emails/prescription-submitted.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { Heading, Section, Text } from '@react-email/components';
|
||||
import { EmailLayout } from './components/EmailLayout';
|
||||
import { Button } from './components/Button';
|
||||
import { StatusBanner } from './components/StatusBanner';
|
||||
import { styles } from './components/styles';
|
||||
import { tCommon } from './i18n/common';
|
||||
import { tTemplate } from './i18n/templates';
|
||||
import { resolveLocale, type Locale } from './i18n/types';
|
||||
import { interpolate } from './utils/interpolate';
|
||||
import { theme } from './theme';
|
||||
|
||||
export interface PrescriptionSubmittedEmailProps {
|
||||
locale?: Locale;
|
||||
userName?: string;
|
||||
rxId?: string;
|
||||
ctaUrl?: string;
|
||||
}
|
||||
|
||||
export const PrescriptionSubmittedEmail = ({
|
||||
locale,
|
||||
userName = 'Abebe Kebede',
|
||||
rxId = 'RX-5521',
|
||||
ctaUrl = `${theme.brand.website}/prescriptions/${rxId}`,
|
||||
}: PrescriptionSubmittedEmailProps) => {
|
||||
const l = resolveLocale(locale);
|
||||
const t = tTemplate('prescription-submitted', l);
|
||||
const common = tCommon(l);
|
||||
return (
|
||||
<EmailLayout preview={interpolate(t.preview, { rxId })} locale={l}>
|
||||
<StatusBanner tone="info">{t.banner}</StatusBanner>
|
||||
<Heading style={styles.heading}>{t.heading}</Heading>
|
||||
<Text style={styles.text}>{interpolate(t.body1, { userName, rxId })}</Text>
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href={ctaUrl}>{t.cta}</Button>
|
||||
</Section>
|
||||
<Text style={styles.muted}>{common.regards}<br />{common.team}</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
PrescriptionSubmittedEmail.PreviewProps = { locale: 'en' } as PrescriptionSubmittedEmailProps;
|
||||
export default PrescriptionSubmittedEmail;
|
||||
46
emails/procurement-alert.tsx
Normal file
46
emails/procurement-alert.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { Heading, Section, Text } from '@react-email/components';
|
||||
import { EmailLayout } from './components/EmailLayout';
|
||||
import { Button } from './components/Button';
|
||||
import { StatusBanner } from './components/StatusBanner';
|
||||
import { styles } from './components/styles';
|
||||
import { tCommon } from './i18n/common';
|
||||
import { tTemplate } from './i18n/templates';
|
||||
import { resolveLocale, type Locale } from './i18n/types';
|
||||
import { interpolate } from './utils/interpolate';
|
||||
import { theme } from './theme';
|
||||
|
||||
export interface ProcurementAlertEmailProps {
|
||||
locale?: Locale;
|
||||
userName?: string;
|
||||
title?: string;
|
||||
supplierName?: string;
|
||||
priority?: string;
|
||||
ctaUrl?: string;
|
||||
}
|
||||
|
||||
export const ProcurementAlertEmail = ({
|
||||
locale,
|
||||
userName = 'Stock Manager',
|
||||
title = 'PO-883 awaiting supplier confirmation',
|
||||
supplierName = 'Addis Med Wholesale',
|
||||
priority = 'High',
|
||||
ctaUrl = `${theme.brand.website}/admin/procurement`,
|
||||
}: ProcurementAlertEmailProps) => {
|
||||
const l = resolveLocale(locale);
|
||||
const t = tTemplate('procurement-alert', l);
|
||||
const common = tCommon(l);
|
||||
return (
|
||||
<EmailLayout preview={interpolate(t.preview, { title })} locale={l}>
|
||||
<StatusBanner tone="warning">{t.banner}</StatusBanner>
|
||||
<Heading style={styles.heading}>{t.heading}</Heading>
|
||||
<Text style={styles.text}>{interpolate(t.body1, { userName, title, supplierName, priority })}</Text>
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href={ctaUrl} variant="accent">{t.cta}</Button>
|
||||
</Section>
|
||||
<Text style={styles.muted}>{common.regards}<br />{common.team}</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
ProcurementAlertEmail.PreviewProps = { locale: 'en' } as ProcurementAlertEmailProps;
|
||||
export default ProcurementAlertEmail;
|
||||
47
emails/promotional.tsx
Normal file
47
emails/promotional.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { Heading, Section, Text } from '@react-email/components';
|
||||
import { EmailLayout } from './components/EmailLayout';
|
||||
import { Button } from './components/Button';
|
||||
import { StatusBanner } from './components/StatusBanner';
|
||||
import { styles } from './components/styles';
|
||||
import { tCommon } from './i18n/common';
|
||||
import { tTemplate } from './i18n/templates';
|
||||
import { resolveLocale, type Locale } from './i18n/types';
|
||||
import { interpolate } from './utils/interpolate';
|
||||
import { theme } from './theme';
|
||||
|
||||
export interface PromotionalEmailProps {
|
||||
locale?: Locale;
|
||||
headline?: string;
|
||||
body?: string;
|
||||
cta?: string;
|
||||
ctaUrl?: string;
|
||||
}
|
||||
|
||||
export const PromotionalEmail = ({
|
||||
locale,
|
||||
headline = 'Seasonal wellness essentials',
|
||||
body = 'Restock vitamins and cold-care products with free delivery on orders over ETB 1,000 this week.',
|
||||
cta = 'Shop the promotion',
|
||||
ctaUrl = `${theme.brand.website}/shop`,
|
||||
}: PromotionalEmailProps) => {
|
||||
const l = resolveLocale(locale);
|
||||
const t = tTemplate('promotional', l);
|
||||
const common = tCommon(l);
|
||||
const amHeadline = l === 'am' ? 'የወቅቱ የጤና እቃዎች' : headline;
|
||||
const amBody = l === 'am' ? 'በዚህ ሳምንት ከETB 1,000 በላይ ትዕዛዞች ነፃ ማድረስ ጋር ቫይታሚኖችን እና የቅዝቃዜ እንክብካቤ ምርቶችን እንደገና ያስቀምጡ።' : body;
|
||||
const amCta = l === 'am' ? 'ማስተዋወቂያውን ይግዙ' : cta;
|
||||
return (
|
||||
<EmailLayout preview={interpolate(t.preview, { headline: amHeadline })} locale={l}>
|
||||
<StatusBanner tone="brand">{t.banner}</StatusBanner>
|
||||
<Heading style={styles.heading}>{interpolate(t.heading, { headline: amHeadline })}</Heading>
|
||||
<Text style={styles.text}>{interpolate(t.body1, { body: amBody })}</Text>
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href={ctaUrl}>{interpolate(t.cta, { cta: amCta })}</Button>
|
||||
</Section>
|
||||
<Text style={styles.muted}>{common.regards}<br />{common.team}</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
PromotionalEmail.PreviewProps = { locale: 'en' } as PromotionalEmailProps;
|
||||
export default PromotionalEmail;
|
||||
79
emails/registry.ts
Normal file
79
emails/registry.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import type { ComponentType } from 'react';
|
||||
import type { Locale } from './i18n/types';
|
||||
import type { ShopSummaryPeriod } from './shop-summary';
|
||||
import { WelcomeEmail } from './welcome';
|
||||
import { PasswordResetEmail } from './password-reset';
|
||||
import { EmailVerifyEmail } from './email-verify';
|
||||
import { UserInviteEmail } from './user-invite';
|
||||
import { OrderConfirmationEmail } from './order-confirmation';
|
||||
import { OrderPackingEmail } from './order-packing';
|
||||
import { OrderOutForDeliveryEmail } from './order-out-for-delivery';
|
||||
import { OrderDeliveredEmail } from './order-delivered';
|
||||
import { OrderCancelledEmail } from './order-cancelled';
|
||||
import { OrgInviteEmail } from './org-invite';
|
||||
import { JoinCodeEmail } from './join-code';
|
||||
import { ApprovalNeededEmail } from './approval-needed';
|
||||
import { StatementReadyEmail } from './statement-ready';
|
||||
import { CreditAlertEmail } from './credit-alert';
|
||||
import { OrganisationVerifiedEmail } from './organisation-verified';
|
||||
import { PrescriptionSubmittedEmail } from './prescription-submitted';
|
||||
import { PrescriptionDecisionEmail } from './prescription-decision';
|
||||
import { PrescriptionReadyEmail } from './prescription-ready';
|
||||
import { LowStockEmail } from './low-stock';
|
||||
import { DispatchAssignedEmail } from './dispatch-assigned';
|
||||
import { ProcurementAlertEmail } from './procurement-alert';
|
||||
import { PromotionalEmail } from './promotional';
|
||||
import { ShopSummaryEmail } from './shop-summary';
|
||||
|
||||
export type TemplateCategory =
|
||||
| 'Auth'
|
||||
| 'Orders'
|
||||
| 'B2B'
|
||||
| 'Prescriptions'
|
||||
| 'Admin ops'
|
||||
| 'Marketing'
|
||||
| 'Analytics';
|
||||
|
||||
export interface TemplateMeta {
|
||||
id: string;
|
||||
name: string;
|
||||
category: TemplateCategory;
|
||||
component: ComponentType<{ locale?: Locale; period?: ShopSummaryPeriod }>;
|
||||
defaultProps?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const templateCatalog: TemplateMeta[] = [
|
||||
{ id: 'welcome', name: 'Welcome', category: 'Auth', component: WelcomeEmail },
|
||||
{ id: 'password-reset', name: 'Password reset', category: 'Auth', component: PasswordResetEmail },
|
||||
{ id: 'email-verify', name: 'Email verify', category: 'Auth', component: EmailVerifyEmail },
|
||||
{ id: 'user-invite', name: 'User invite', category: 'Auth', component: UserInviteEmail },
|
||||
{ id: 'order-confirmation', name: 'Order confirmation', category: 'Orders', component: OrderConfirmationEmail },
|
||||
{ id: 'order-packing', name: 'Order packing', category: 'Orders', component: OrderPackingEmail },
|
||||
{ id: 'order-out-for-delivery', name: 'Out for delivery', category: 'Orders', component: OrderOutForDeliveryEmail },
|
||||
{ id: 'order-delivered', name: 'Order delivered', category: 'Orders', component: OrderDeliveredEmail },
|
||||
{ id: 'order-cancelled', name: 'Order cancelled', category: 'Orders', component: OrderCancelledEmail },
|
||||
{ id: 'org-invite', name: 'Organisation invite', category: 'B2B', component: OrgInviteEmail },
|
||||
{ id: 'join-code', name: 'Join code', category: 'B2B', component: JoinCodeEmail },
|
||||
{ id: 'approval-needed', name: 'Approval needed', category: 'B2B', component: ApprovalNeededEmail },
|
||||
{ id: 'statement-ready', name: 'Statement ready', category: 'B2B', component: StatementReadyEmail },
|
||||
{ id: 'credit-alert', name: 'Credit alert', category: 'B2B', component: CreditAlertEmail },
|
||||
{ id: 'organisation-verified', name: 'Organisation verified', category: 'B2B', component: OrganisationVerifiedEmail },
|
||||
{ id: 'prescription-submitted', name: 'Prescription submitted', category: 'Prescriptions', component: PrescriptionSubmittedEmail },
|
||||
{ id: 'prescription-decision', name: 'Prescription decision', category: 'Prescriptions', component: PrescriptionDecisionEmail },
|
||||
{ id: 'prescription-ready', name: 'Prescription ready', category: 'Prescriptions', component: PrescriptionReadyEmail },
|
||||
{ id: 'low-stock', name: 'Low stock', category: 'Admin ops', component: LowStockEmail },
|
||||
{ id: 'dispatch-assigned', name: 'Dispatch assigned', category: 'Admin ops', component: DispatchAssignedEmail },
|
||||
{ id: 'procurement-alert', name: 'Procurement alert', category: 'Admin ops', component: ProcurementAlertEmail },
|
||||
{ id: 'promotional', name: 'Promotional', category: 'Marketing', component: PromotionalEmail },
|
||||
{
|
||||
id: 'shop-summary',
|
||||
name: 'Shop summary',
|
||||
category: 'Analytics',
|
||||
component: ShopSummaryEmail,
|
||||
defaultProps: { period: 'weekly' as ShopSummaryPeriod },
|
||||
},
|
||||
];
|
||||
|
||||
export const templateMap = Object.fromEntries(
|
||||
templateCatalog.map((t) => [t.id, t])
|
||||
) as Record<string, TemplateMeta>;
|
||||
70
emails/shop-summary.tsx
Normal file
70
emails/shop-summary.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { Heading, Section, Text } from '@react-email/components';
|
||||
import { EmailLayout } from './components/EmailLayout';
|
||||
import { Button } from './components/Button';
|
||||
import { MetricGrid } from './components/MetricGrid';
|
||||
import { styles } from './components/styles';
|
||||
import { tCommon } from './i18n/common';
|
||||
import { tTemplate } from './i18n/templates';
|
||||
import { resolveLocale, type Locale } from './i18n/types';
|
||||
import { interpolate } from './utils/interpolate';
|
||||
import { theme } from './theme';
|
||||
|
||||
export type ShopSummaryPeriod = 'daily' | 'weekly' | 'monthly' | 'quarterly' | 'yearly';
|
||||
|
||||
const periodLabels: Record<ShopSummaryPeriod, { en: string; am: string }> = {
|
||||
daily: { en: 'Daily', am: 'ዕለታዊ' },
|
||||
weekly: { en: 'Weekly', am: 'ሳምንታዊ' },
|
||||
monthly: { en: 'Monthly', am: 'ወርሃዊ' },
|
||||
quarterly: { en: 'Quarterly', am: 'ሩብ ዓመታዊ' },
|
||||
yearly: { en: 'Yearly', am: 'ዓመታዊ' },
|
||||
};
|
||||
|
||||
export interface ShopSummaryEmailProps {
|
||||
locale?: Locale;
|
||||
period?: ShopSummaryPeriod;
|
||||
userName?: string;
|
||||
dateRange?: string;
|
||||
ordersCount?: string;
|
||||
revenue?: string;
|
||||
averageOrderValue?: string;
|
||||
topSku?: string;
|
||||
ctaUrl?: string;
|
||||
}
|
||||
|
||||
export const ShopSummaryEmail = ({
|
||||
locale,
|
||||
period = 'weekly',
|
||||
userName = 'Marketing Lead',
|
||||
dateRange = '28 Jul – 3 Aug 2026',
|
||||
ordersCount = '486',
|
||||
revenue = 'ETB 1.24M',
|
||||
averageOrderValue = 'ETB 2,551',
|
||||
topSku = 'Paracetamol 500mg',
|
||||
ctaUrl = `${theme.brand.website}/admin/analytics`,
|
||||
}: ShopSummaryEmailProps) => {
|
||||
const l = resolveLocale(locale);
|
||||
const t = tTemplate('shop-summary', l);
|
||||
const common = tCommon(l);
|
||||
const periodLabel = periodLabels[period][l];
|
||||
return (
|
||||
<EmailLayout preview={interpolate(t.preview, { periodLabel })} locale={l}>
|
||||
<Heading style={styles.heading}>{interpolate(t.heading, { periodLabel })}</Heading>
|
||||
<Text style={styles.text}>{interpolate(t.body1, { userName, periodLabel, dateRange })}</Text>
|
||||
<MetricGrid
|
||||
metrics={[
|
||||
{ label: t.orders, value: ordersCount },
|
||||
{ label: t.revenue, value: revenue },
|
||||
{ label: t.aov, value: averageOrderValue },
|
||||
{ label: t.topSku, value: topSku },
|
||||
]}
|
||||
/>
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href={ctaUrl}>{t.cta}</Button>
|
||||
</Section>
|
||||
<Text style={styles.muted}>{common.regards}<br />{common.team}</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
ShopSummaryEmail.PreviewProps = { locale: 'en', period: 'weekly' } as ShopSummaryEmailProps;
|
||||
export default ShopSummaryEmail;
|
||||
46
emails/statement-ready.tsx
Normal file
46
emails/statement-ready.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { Heading, Section, Text } from '@react-email/components';
|
||||
import { EmailLayout } from './components/EmailLayout';
|
||||
import { Button } from './components/Button';
|
||||
import { StatusBanner } from './components/StatusBanner';
|
||||
import { styles } from './components/styles';
|
||||
import { tCommon } from './i18n/common';
|
||||
import { tTemplate } from './i18n/templates';
|
||||
import { resolveLocale, type Locale } from './i18n/types';
|
||||
import { interpolate } from './utils/interpolate';
|
||||
import { theme } from './theme';
|
||||
|
||||
export interface StatementReadyEmailProps {
|
||||
locale?: Locale;
|
||||
userName?: string;
|
||||
orgName?: string;
|
||||
periodLabel?: string;
|
||||
amountDue?: string;
|
||||
ctaUrl?: string;
|
||||
}
|
||||
|
||||
export const StatementReadyEmail = ({
|
||||
locale,
|
||||
userName = 'Helen Tadesse',
|
||||
orgName = 'Yaltopia Tech',
|
||||
periodLabel = 'July 2026',
|
||||
amountDue = 'ETB 184,200',
|
||||
ctaUrl = `${theme.brand.website}/b2b/statements`,
|
||||
}: StatementReadyEmailProps) => {
|
||||
const l = resolveLocale(locale);
|
||||
const t = tTemplate('statement-ready', l);
|
||||
const common = tCommon(l);
|
||||
return (
|
||||
<EmailLayout preview={interpolate(t.preview, { periodLabel })} locale={l}>
|
||||
<StatusBanner tone="info">{interpolate(t.banner, { amountDue })}</StatusBanner>
|
||||
<Heading style={styles.heading}>{t.heading}</Heading>
|
||||
<Text style={styles.text}>{interpolate(t.body1, { userName, orgName, periodLabel })}</Text>
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href={ctaUrl}>{t.cta}</Button>
|
||||
</Section>
|
||||
<Text style={styles.muted}>{common.regards}<br />{common.team}</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
StatementReadyEmail.PreviewProps = { locale: 'en' } as StatementReadyEmailProps;
|
||||
export default StatementReadyEmail;
|
||||
36
emails/theme.ts
Normal file
36
emails/theme.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
export const theme = {
|
||||
colors: {
|
||||
brand: '#A8C73A',
|
||||
brandDeep: '#8FAD2F',
|
||||
forest: '#141414',
|
||||
accent: '#E28A1A',
|
||||
cream: '#F6F7F2',
|
||||
surface: '#FFFFFF',
|
||||
line: '#E2E5D8',
|
||||
muted: '#5C5F56',
|
||||
danger: '#C0392B',
|
||||
success: '#3D7A1F',
|
||||
info: '#2F5D8C',
|
||||
},
|
||||
fonts: {
|
||||
sans: '"DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||
},
|
||||
spacing: {
|
||||
xs: '8px',
|
||||
sm: '16px',
|
||||
md: '24px',
|
||||
lg: '32px',
|
||||
xl: '48px',
|
||||
},
|
||||
brand: {
|
||||
name: 'Gishen Pharmacy',
|
||||
shortName: 'Gishen',
|
||||
email: 'info@gishenpharmacy.org',
|
||||
b2bEmail: 'b2b@gishenpharmacy.org',
|
||||
website: 'https://gishenpharmacy.org',
|
||||
address: 'Addis Ababa, Ethiopia',
|
||||
phone: '+251 930 099 850',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type Theme = typeof theme;
|
||||
43
emails/user-invite.tsx
Normal file
43
emails/user-invite.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { Heading, Section, Text } from '@react-email/components';
|
||||
import { EmailLayout } from './components/EmailLayout';
|
||||
import { Button } from './components/Button';
|
||||
import { styles } from './components/styles';
|
||||
import { tCommon } from './i18n/common';
|
||||
import { tTemplate } from './i18n/templates';
|
||||
import { resolveLocale, type Locale } from './i18n/types';
|
||||
import { interpolate } from './utils/interpolate';
|
||||
import { theme } from './theme';
|
||||
|
||||
export interface UserInviteEmailProps {
|
||||
locale?: Locale;
|
||||
orgName?: string;
|
||||
inviterName?: string;
|
||||
role?: string;
|
||||
inviteUrl?: string;
|
||||
}
|
||||
|
||||
export const UserInviteEmail = ({
|
||||
locale,
|
||||
orgName = 'Yaltopia Tech',
|
||||
inviterName = 'Meron Hailu',
|
||||
role = 'Member',
|
||||
inviteUrl = `${theme.brand.website}/invite`,
|
||||
}: UserInviteEmailProps) => {
|
||||
const l = resolveLocale(locale);
|
||||
const t = tTemplate('user-invite', l);
|
||||
const common = tCommon(l);
|
||||
return (
|
||||
<EmailLayout preview={interpolate(t.preview, { orgName })} locale={l}>
|
||||
<Heading style={styles.heading}>{interpolate(t.heading, { orgName })}</Heading>
|
||||
<Text style={styles.text}>{interpolate(t.body1, { inviterName, role })}</Text>
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href={inviteUrl}>{t.cta}</Button>
|
||||
</Section>
|
||||
<Text style={styles.muted}>{t.body2}</Text>
|
||||
<Text style={styles.muted}>{common.regards}<br />{common.team}</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
UserInviteEmail.PreviewProps = { locale: 'en' } as UserInviteEmailProps;
|
||||
export default UserInviteEmail;
|
||||
3
emails/utils/interpolate.ts
Normal file
3
emails/utils/interpolate.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function interpolate(template: string, vars: Record<string, string | number>) {
|
||||
return template.replace(/\{(\w+)\}/g, (_, key: string) => String(vars[key] ?? ''));
|
||||
}
|
||||
1
emails/utils/logoDataUri.ts
Normal file
1
emails/utils/logoDataUri.ts
Normal file
File diff suppressed because one or more lines are too long
40
emails/welcome.tsx
Normal file
40
emails/welcome.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { Heading, Section, Text } from '@react-email/components';
|
||||
import { EmailLayout } from './components/EmailLayout';
|
||||
import { Button } from './components/Button';
|
||||
import { styles } from './components/styles';
|
||||
import { tCommon } from './i18n/common';
|
||||
import { tTemplate } from './i18n/templates';
|
||||
import { resolveLocale, type Locale } from './i18n/types';
|
||||
import { interpolate } from './utils/interpolate';
|
||||
import { theme } from './theme';
|
||||
|
||||
export interface WelcomeEmailProps {
|
||||
locale?: Locale;
|
||||
userName?: string;
|
||||
ctaUrl?: string;
|
||||
}
|
||||
|
||||
export const WelcomeEmail = ({
|
||||
locale,
|
||||
userName = 'Abebe Kebede',
|
||||
ctaUrl = theme.brand.website,
|
||||
}: WelcomeEmailProps) => {
|
||||
const l = resolveLocale(locale);
|
||||
const t = tTemplate('welcome', l);
|
||||
const common = tCommon(l);
|
||||
return (
|
||||
<EmailLayout preview={t.preview} locale={l}>
|
||||
<Heading style={styles.heading}>{interpolate(t.heading, { userName })}</Heading>
|
||||
<Text style={styles.text}>{t.body1}</Text>
|
||||
<Text style={styles.text}>{t.body2}</Text>
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href={ctaUrl}>{t.cta}</Button>
|
||||
</Section>
|
||||
<Text style={styles.text}>{t.closing}</Text>
|
||||
<Text style={styles.muted}>{common.regards}<br />{common.team}</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
WelcomeEmail.PreviewProps = { userName: 'Abebe Kebede', locale: 'en' } as WelcomeEmailProps;
|
||||
export default WelcomeEmail;
|
||||
5
next-env.d.ts
vendored
Normal file
5
next-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
6
next.config.js
Normal file
6
next.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
7547
package-lock.json
generated
Normal file
7547
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "gishen-email-templates",
|
||||
"version": "0.1.0",
|
||||
"description": "Email templates for Gishen Pharmacy (Ecom, Admin, B2B)",
|
||||
"scripts": {
|
||||
"email": "email dev",
|
||||
"build": "email export",
|
||||
"preview": "next dev",
|
||||
"dev": "next dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-email/components": "^0.0.25",
|
||||
"@react-email/render": "^1.0.4",
|
||||
"next": "^14.2.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"react-email": "^2.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
BIN
public/brand/gishen-logo.png
Normal file
BIN
public/brand/gishen-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
24
tsconfig.json
Normal file
24
tsconfig.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user