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:
Kirubel-Kibru-Yaltopia 2026-08-07 22:12:21 +03:00
parent 8261920b9e
commit f9fa3bc83b
50 changed files with 10332 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
node_modules
.next
out
emails-export
.DS_Store
*.log
.env
.env.local

25
CHANGELOG.md Normal file
View 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

View File

@ -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.

View 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
View 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
View 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,
};

View 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

View 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;

View 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>
);
};

View 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',
};

View 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',
};

View 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,
};

View 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,
};

View 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>
);
};

View 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
View 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;

View 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
View 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
View 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];
}

View 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
View 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
View 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
View 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;

View 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;

View 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;

View 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;

View 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
View 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
View 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;

View 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
View 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;

View 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;

View 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;

View 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;

View 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
View 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
View 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
View 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;

View 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
View 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
View 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;

View 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] ?? ''));
}

File diff suppressed because one or more lines are too long

40
emails/welcome.tsx Normal file
View 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
View 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
View File

@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
};
module.exports = nextConfig;

7547
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

24
tsconfig.json Normal file
View 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"]
}