Skip to content

Commit e1fcea5

Browse files
committed
feat(email): storybook like email package
1 parent e228023 commit e1fcea5

19 files changed

+648
-1
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/node_modules
1+
node_modules
22
*.swp
33
*.swo
44
*.orig
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!doctype html>
2+
<html lang="en" style="margin: 0; padding: 0">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Storybook like . Email Preview</title>
7+
</head>
8+
<body style="margin: 0; padding: 0">
9+
<div id="root"></div>
10+
<script type="module" src="/.storybook/index.tsx"></script>
11+
</body>
12+
</html>
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
//
2+
3+
import type { Component, PropsWithChildren } from "@kitajs/html";
4+
import type {
5+
ComponentAnnotations,
6+
Renderer,
7+
StoryAnnotations,
8+
} from "@storybook/csf";
9+
10+
//
11+
12+
document.getElementById("root")!.innerHTML = await Root();
13+
window.addEventListener("hashchange", async () => {
14+
document.getElementById("root")!.innerHTML = await Root();
15+
});
16+
17+
//
18+
19+
function Root() {
20+
return (
21+
<AppContainer>
22+
<Navbar>
23+
<NavbarHeader>
24+
<h3>Email Templates</h3>
25+
</NavbarHeader>
26+
27+
<StoriesList />
28+
</Navbar>
29+
<Main>
30+
<TitleHeader />
31+
<EmailContent />
32+
</Main>
33+
</AppContainer>
34+
);
35+
}
36+
37+
//
38+
39+
function StoriesList() {
40+
const stories = getStoriesFiles();
41+
return (
42+
<ul
43+
style={{
44+
listStyleType: "none",
45+
padding: 0,
46+
}}
47+
>
48+
{stories.map((value) => (
49+
<StoriesListItem value={value} />
50+
))}
51+
</ul>
52+
);
53+
}
54+
55+
function StoriesListItem({
56+
value: [file, story],
57+
}: {
58+
value: [string, StoriesModule];
59+
}) {
60+
const isActive = location.hash.slice(2).split("#")[0] === file;
61+
const { default: defaultStory, ...variantStories } = story;
62+
63+
return (
64+
<li>
65+
<a
66+
style={{
67+
borderRight: isActive ? "2px solid #273142" : "2px solid transparent",
68+
color: "currentcolor",
69+
display: "block",
70+
padding: "10px",
71+
paddingLeft: "20px",
72+
}}
73+
href={`#/${file}`}
74+
>
75+
{defaultStory.title || file}
76+
</a>
77+
<VariantStoriesList value={{ file, stories: variantStories }} />
78+
</li>
79+
);
80+
}
81+
function VariantStoriesList({
82+
value: { file, stories },
83+
}: {
84+
value: { file: string; stories: VariantStorie };
85+
}) {
86+
const entries = Object.entries(stories) as [string, VariantStorie][];
87+
if (entries.length === 0) return null;
88+
return (
89+
<ul>
90+
{entries.map(([key, story]) => (
91+
<li>
92+
<StoryLink value={{ file, key }}>{story.name || key}</StoryLink>
93+
</li>
94+
))}
95+
</ul>
96+
);
97+
}
98+
99+
function StoryLink(
100+
props: PropsWithChildren<{ value: { file: string; key: string } }>,
101+
) {
102+
const { children } = props;
103+
const { file, key } = props.value;
104+
const href = `#/${file}#${key}`;
105+
const isActive = location.hash === href;
106+
return (
107+
<a
108+
href={href}
109+
style={{
110+
borderRight: isActive ? "2px solid #273142" : "2px solid transparent",
111+
color: "currentcolor",
112+
display: "block",
113+
}}
114+
>
115+
{children}
116+
</a>
117+
);
118+
}
119+
async function TitleHeader() {
120+
const file = getFileNameFromLocation();
121+
if (!file) return <></>;
122+
const [story_err, story] = await getStory(file);
123+
if (story_err) return <></>;
124+
return (
125+
<header
126+
style={{
127+
backgroundColor: "#f0f0f0",
128+
padding: "20px",
129+
borderBottom: "1px solid #e6ebf1",
130+
}}
131+
>
132+
{story.default.title}
133+
</header>
134+
);
135+
}
136+
137+
async function EmailContent() {
138+
const file = getFileNameFromLocation();
139+
if (!file) return <Welcome />;
140+
141+
const [story_err, story] = await getStory(file);
142+
if (story_err) {
143+
return (
144+
<Error>
145+
{story_err.name}
146+
<br />
147+
{story_err.message}
148+
</Error>
149+
);
150+
}
151+
const variant = getVariantFromLocation();
152+
const variantAnnotation = story[variant];
153+
if (variant && !variantAnnotation)
154+
return (
155+
<Error>
156+
Not Found
157+
<br />
158+
Unknown variant "{variant}" in {file}
159+
</Error>
160+
);
161+
const meta = story.default;
162+
const defaultArgs = meta.args || {};
163+
const args = { ...defaultArgs, ...variantAnnotation?.args };
164+
return <section>{meta.render(args)}</section>;
165+
}
166+
167+
interface Meta extends ComponentAnnotations<Renderer> {
168+
render: Component;
169+
}
170+
171+
type VariantStorie = StoryAnnotations<Renderer>;
172+
type StoriesModule = {
173+
default: Meta;
174+
} & Partial<Record<string, VariantStorie>>;
175+
176+
function getStoriesFiles() {
177+
return Object.entries(
178+
import.meta.glob("../src/**/*.stories.tsx", {
179+
eager: true,
180+
}),
181+
) as [string, StoriesModule][];
182+
}
183+
184+
async function getStory(
185+
file: string,
186+
): Promise<[Error, null] | [null, StoriesModule]> {
187+
try {
188+
return [null, await import(/* @vite-ignore */ file)];
189+
} catch (e) {
190+
return [e as Error, null];
191+
}
192+
}
193+
194+
function getFileNameFromLocation() {
195+
return location.hash.slice(2).split("#")[0];
196+
}
197+
function getVariantFromLocation() {
198+
return location.hash.slice(2).split("#")[1];
199+
}
200+
//
201+
202+
function AppContainer({ children }: PropsWithChildren) {
203+
return (
204+
<nav
205+
style={{
206+
display: "flex",
207+
height: "100vh",
208+
}}
209+
>
210+
{children}
211+
</nav>
212+
);
213+
}
214+
function Navbar({ children }: PropsWithChildren) {
215+
return (
216+
<nav
217+
style={{
218+
width: "250px",
219+
backgroundColor: "#f0f0f0",
220+
overflowY: "auto",
221+
borderRight: "1px solid #e6ebf1",
222+
}}
223+
>
224+
{children}
225+
</nav>
226+
);
227+
}
228+
229+
function NavbarHeader({ children }: PropsWithChildren) {
230+
return <header style={{ margin: "20px" }}>{children}</header>;
231+
}
232+
233+
function Main({ children }: PropsWithChildren) {
234+
return (
235+
<main
236+
style={{
237+
display: "flex",
238+
flexDirection: "column",
239+
flexGrow: 1,
240+
overflowY: "auto",
241+
}}
242+
>
243+
{children}
244+
</main>
245+
);
246+
}
247+
248+
function Error({ children }: PropsWithChildren) {
249+
return (
250+
<pre
251+
style={{
252+
backgroundColor: "black",
253+
color: "white",
254+
flexGrow: 1,
255+
margin: "0",
256+
overflowY: "auto",
257+
padding: "20px",
258+
}}
259+
>
260+
{children}
261+
</pre>
262+
);
263+
}
264+
265+
function Welcome() {
266+
return (
267+
<section
268+
style={{
269+
backgroundColor: "black",
270+
color: "white",
271+
flexGrow: 1,
272+
padding: "20px",
273+
}}
274+
>
275+
<h1>Welcome to the Email Template "Storybook"</h1>
276+
277+
<p> 🫲 Click on a story to see it in action.</p>
278+
</section>
279+
);
280+
}

packages/email/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Email

packages/email/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<meta http-equiv="refresh" content="0; url=/.storybook/" />

packages/email/package.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"name": "@numerique-gouv/moncomptepro.email",
3+
"version": "0.0.0",
4+
"private": true,
5+
"type": "module",
6+
"exports": {
7+
".": "./src/index.ts"
8+
},
9+
"scripts": {
10+
"dev": "vite"
11+
},
12+
"dependencies": {
13+
"@kitajs/html": "^4.2.2",
14+
"@kitajs/ts-html-plugin": "^4.1.0"
15+
},
16+
"devDependencies": {
17+
"@storybook/csf": "^0.1.11",
18+
"vite": "^5.4.8"
19+
}
20+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
//
2+
3+
import type { ComponentAnnotations, Renderer } from "@storybook/csf";
4+
import DeleteFreeTotpMail, { type Props } from "./DeleteFreeTotpMail";
5+
6+
//
7+
8+
export default {
9+
title: "Delete Free TOTP",
10+
render: DeleteFreeTotpMail,
11+
args: {
12+
given_name: "Marie",
13+
family_name: "Dupont",
14+
support_email: "contact@moncomptepro.beta.gouv.fr",
15+
},
16+
} as ComponentAnnotations<Renderer, Props>;
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
//
2+
3+
import { Layout } from "./_layout";
4+
import { Link, Text } from "./components";
5+
6+
//
7+
8+
export default function DeleteFreeTotpMail(props: Props) {
9+
const { given_name, family_name, support_email } = props;
10+
const mailtoParams = new URLSearchParams({
11+
subject: "Erreur - Mon organisation",
12+
});
13+
const mailtoHref = `mailto:${support_email}?${mailtoParams.toString()}`;
14+
return (
15+
<Layout>
16+
<Text safe>
17+
Bonjour {given_name} {family_name},
18+
</Text>
19+
<br />
20+
<br />
21+
<Text>
22+
L'application a été supprimée comme étape de connexion à deux facteurs.
23+
<br />
24+
<br />
25+
<Link href={mailtoHref}>
26+
Si vous n'avez pas supprimé cette application, quelqu'un utilise
27+
peut-être votre compte. Faites-le nous savoir en répondant à cet
28+
email.
29+
</Link>
30+
<br />
31+
<br />
32+
Cordialement,
33+
<br />
34+
<br />
35+
L’équipe MonComptePro
36+
</Text>
37+
</Layout>
38+
);
39+
}
40+
41+
//
42+
43+
export type Props = {
44+
given_name: string;
45+
family_name: string;
46+
support_email: string;
47+
};

0 commit comments

Comments
 (0)