Skip to content

Commit ddb59e0

Browse files
committed
feat: emailtemplates
1 parent 93c7f54 commit ddb59e0

File tree

19 files changed

+1113
-243
lines changed

19 files changed

+1113
-243
lines changed

app/app.vue

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ const items: NavigationMenuItem[] = [
104104
105105
const uiStore = useUIStore();
106106
const jsonStore = useJsonStore();
107+
const emailStore = useEmailStore();
107108
108109
onMounted(() => {
109110
if (
@@ -125,5 +126,19 @@ onMounted(() => {
125126
jsonStore.setTranslations(jsonStore.input, key);
126127
}
127128
});
129+
130+
typedKeys(emailStore.$state).forEach((g) => {
131+
if (!emailStore[g]) return;
132+
typedKeys(emailStore[g]).forEach((nr) => {
133+
if (!emailStore[g]![nr]) return;
134+
135+
if (emailStore[g]![nr]!.input) {
136+
emailStore[g]![nr]!.translations = {
137+
...emailStore[g]![nr]!.input,
138+
...(emailStore[g]![nr]!.translations ?? {}),
139+
};
140+
}
141+
});
142+
});
128143
});
129144
</script>

app/components/EmailForm.vue

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<template>
2+
<UForm
3+
:state="state"
4+
:schema="schema"
5+
class="flex flex-col gap-4"
6+
@submit="onSubmit"
7+
>
8+
<UFormField
9+
name="title"
10+
:label="`E-mail #${nr}`"
11+
:description="original.title"
12+
>
13+
<UInput v-model="state.title" class="w-full" />
14+
</UFormField>
15+
<UFormField label="Origineel">
16+
<UTextarea
17+
readonly
18+
:rows="1"
19+
autoresize
20+
class="w-full"
21+
:model-value="original.text"
22+
/>
23+
</UFormField>
24+
<UFormField name="text" label="Vertaling">
25+
<UTextarea v-model="state.text" :rows="1" autoresize class="w-full" />
26+
</UFormField>
27+
<UButton class="w-fit" type="submit" label="Opslaan" />
28+
</UForm>
29+
</template>
30+
<script setup lang="ts">
31+
import type { FormSubmitEvent } from "@nuxt/ui";
32+
33+
import { z } from "zod";
34+
35+
const props = defineProps<{
36+
group: EmailKey;
37+
nr: number;
38+
}>();
39+
40+
const emailStore = useEmailStore();
41+
42+
const original = computed(
43+
() => emailStore[props.group]?.[props.nr]?.originals ?? {},
44+
);
45+
46+
const translation = computed(
47+
() => emailStore[props.group]?.[props.nr]?.translations ?? {},
48+
);
49+
50+
const schema = z.object({
51+
text: z.string(),
52+
title: z.string(),
53+
});
54+
55+
type Schema = z.infer<typeof schema>;
56+
57+
const state = reactive<Partial<Schema>>({ ...translation.value });
58+
59+
const { showSuccess } = useFlash();
60+
61+
function onSubmit(event: FormSubmitEvent<Schema>) {
62+
console.log(JSON.stringify(event.data, null, 2));
63+
emailStore.setTranslation(props.group, props.nr, event.data);
64+
showSuccess({ description: "E-mail opgeslagen.", id: "email-saved" });
65+
}
66+
</script>

app/components/EmailImportForm.vue

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
<template>
2+
<UForm
3+
:state="state"
4+
:schema="schema"
5+
class="flex flex-col gap-4"
6+
@submit="onSubmit"
7+
>
8+
<fieldset
9+
v-for="emailGroup in emailGroups"
10+
:key="emailGroup.key"
11+
class="flex flex-col gap-4"
12+
>
13+
<legend>{{ emailGroup.label }} ({{ locale }})</legend>
14+
<UFileUpload
15+
v-model="emailFiles"
16+
multiple
17+
layout="list"
18+
position="inside"
19+
accept="text/plain"
20+
:file-delete="false"
21+
class="w-full min-h-48"
22+
:disabled="emailFiles.length > 0"
23+
:label="`Importeer vanuit lokale bestanden (${capitalize(emailGroup.key)} folder)`"
24+
@update:model-value="(v) => loadEmailFiles(v, emailGroup.key)"
25+
/>
26+
<template v-for="i of emailGroup.count" :key="i">
27+
<UFormField
28+
v-if="state[emailGroup.key][i]"
29+
:label="`${i}. Titel`"
30+
:name="`${emailGroup.key}.${i}.text`"
31+
:description="`Vul de titel uit de bestandsnaam in (bijvoorbeeld: '${i}_Titel.txt' -> 'Titel').`"
32+
>
33+
<UInput v-model="state[emailGroup.key][i]!.title" class="w-full" />
34+
</UFormField>
35+
<UFormField
36+
v-if="state[emailGroup.key][i]"
37+
label="Tekst"
38+
:name="`${emailGroup.key}.${i}.title`"
39+
:description="`Plak tekst uit het lokale ${capitalize(emailGroup.key)}/${i}_Titel.txt bestand.`"
40+
>
41+
<UTextarea v-model="state[emailGroup.key][i]!.text" class="w-full" />
42+
</UFormField>
43+
</template>
44+
</fieldset>
45+
<UButton class="w-fit" type="submit" label="Opslaan" />
46+
</UForm>
47+
</template>
48+
49+
<script setup lang="ts">
50+
import type { FormSubmitEvent } from "@nuxt/ui";
51+
52+
import { z } from "zod";
53+
54+
defineProps<{
55+
locale: "Engels" | "Nederlands";
56+
}>();
57+
58+
const model = defineModel<Partial<Output>>();
59+
60+
const defaultState = (
61+
count: number,
62+
): Partial<Record<number, { text?: string; title?: string }>> =>
63+
Object.fromEntries(Array.from({ length: count }, (_, i) => [i + 1, {}]));
64+
65+
const schema = z.object({
66+
assignmentsAndDuties: z.partialRecord(
67+
z.number(),
68+
z.strictObject({
69+
text: z.string().optional(),
70+
title: z.string().optional(),
71+
}),
72+
),
73+
fieldServiceReports: z.partialRecord(
74+
z.number(),
75+
z.strictObject({
76+
text: z.string().optional(),
77+
title: z.string().optional(),
78+
}),
79+
),
80+
lifeAndMinistryMeeting: z.partialRecord(
81+
z.number(),
82+
z.strictObject({
83+
text: z.string().optional(),
84+
title: z.string().optional(),
85+
}),
86+
),
87+
other: z.partialRecord(
88+
z.number(),
89+
z.strictObject({
90+
text: z.string().optional(),
91+
title: z.string().optional(),
92+
}),
93+
),
94+
persons: z.partialRecord(
95+
z.number(),
96+
z.strictObject({
97+
text: z.string().optional(),
98+
title: z.string().optional(),
99+
}),
100+
),
101+
publicTalks: z.partialRecord(
102+
z.number(),
103+
z.strictObject({
104+
text: z.string().optional(),
105+
title: z.string().optional(),
106+
}),
107+
),
108+
schedules: z.partialRecord(
109+
z.number(),
110+
z.strictObject({
111+
text: z.string().optional(),
112+
title: z.string().optional(),
113+
}),
114+
),
115+
territory: z.partialRecord(
116+
z.number(),
117+
z.strictObject({
118+
text: z.string().optional(),
119+
title: z.string().optional(),
120+
}),
121+
),
122+
} satisfies Record<EmailKey, unknown>);
123+
124+
export type Input = z.input<typeof schema>;
125+
export type Output = z.output<typeof schema>;
126+
127+
const state = reactive<Input>({
128+
assignmentsAndDuties: {
129+
...defaultState(
130+
emailGroups.find((group) => group.key === "assignmentsAndDuties")!.count,
131+
),
132+
...(model.value?.assignmentsAndDuties ?? {}),
133+
},
134+
fieldServiceReports: {
135+
...defaultState(
136+
emailGroups.find((group) => group.key === "fieldServiceReports")!.count,
137+
),
138+
...(model.value?.fieldServiceReports ?? {}),
139+
},
140+
lifeAndMinistryMeeting: {
141+
...defaultState(
142+
emailGroups.find((group) => group.key === "lifeAndMinistryMeeting")!
143+
.count,
144+
),
145+
...(model.value?.lifeAndMinistryMeeting ?? {}),
146+
},
147+
other: {
148+
...defaultState(emailGroups.find((group) => group.key === "other")!.count),
149+
...(model.value?.other ?? {}),
150+
},
151+
persons: {
152+
...defaultState(
153+
emailGroups.find((group) => group.key === "persons")!.count,
154+
),
155+
...(model.value?.persons ?? {}),
156+
},
157+
publicTalks: {
158+
...defaultState(
159+
emailGroups.find((group) => group.key === "publicTalks")!.count,
160+
),
161+
...(model.value?.publicTalks ?? {}),
162+
},
163+
schedules: {
164+
...defaultState(
165+
emailGroups.find((group) => group.key === "schedules")!.count,
166+
),
167+
...(model.value?.schedules ?? {}),
168+
},
169+
territory: {
170+
...defaultState(
171+
emailGroups.find((group) => group.key === "territory")!.count,
172+
),
173+
...(model.value?.territory ?? {}),
174+
},
175+
});
176+
177+
watch(
178+
model,
179+
(newVal) => {
180+
if (newVal) {
181+
emailGroups
182+
.map(({ key }) => key)
183+
.forEach((key) => {
184+
state[key] = {
185+
...defaultState(
186+
emailGroups.find((group) => group.key === key)!.count,
187+
),
188+
...Object.fromEntries(
189+
Object.entries(newVal[key] ?? {}).filter(([_, v]) => !!v),
190+
),
191+
};
192+
});
193+
} else {
194+
emailGroups
195+
.map(({ key }) => key)
196+
.forEach((key) => {
197+
state[key] = defaultState(
198+
emailGroups.find((group) => group.key === key)!.count,
199+
);
200+
});
201+
}
202+
},
203+
{ immediate: true },
204+
);
205+
206+
function onSubmit(event: FormSubmitEvent<Output>) {
207+
model.value = event.data;
208+
}
209+
210+
const loadEmailFile = async (
211+
file: File,
212+
group: (typeof emailGroups)[number],
213+
) => {
214+
try {
215+
const [nrString, ...rest] = file.name.split("_");
216+
const title = rest.join("_").replace(".txt", "");
217+
const nr = nrString ? Number(nrString) : undefined;
218+
219+
if (!nr || !title || isNaN(nr) || nr < 1 || nr > group.count) return;
220+
const text = await file.text();
221+
222+
state[group.key] = {
223+
...state[group.key],
224+
[nr]: {
225+
text,
226+
title,
227+
},
228+
};
229+
} catch (e) {
230+
console.error(e);
231+
}
232+
};
233+
234+
const emailFiles = ref<File[]>([]);
235+
236+
const { showError } = useFlash();
237+
238+
const loadEmailFiles = async (
239+
files: File[] | null | undefined,
240+
group: EmailKey,
241+
) => {
242+
if (!files || files.length === 0) return;
243+
244+
const groupInfo = emailGroups.find((g) => g.key === group);
245+
if (!groupInfo) return;
246+
247+
if (files.some((file) => file.type !== "text/plain")) {
248+
emailFiles.value = emailFiles.value.filter(
249+
(file) => file.type === "text/plain",
250+
);
251+
return loadEmailFiles(emailFiles.value, group);
252+
}
253+
254+
if (files.some((file) => !/^\d+_.+\.txt$/i.test(file.name))) {
255+
emailFiles.value = emailFiles.value.filter((file) =>
256+
/^\d+_.+\.txt$/i.test(file.name),
257+
);
258+
return loadEmailFiles(emailFiles.value, group);
259+
}
260+
261+
if (files.length !== groupInfo.count) {
262+
showError({
263+
description: `Er horen ${groupInfo.count} bestanden te zijn voor de groep ${groupInfo.label}. Heb je de juiste folder geopend?`,
264+
id: "email-import-error",
265+
});
266+
emailFiles.value = [];
267+
return;
268+
}
269+
270+
for (const file of files) {
271+
await loadEmailFile(file, groupInfo);
272+
}
273+
274+
emailFiles.value = [];
275+
model.value = { ...state };
276+
};
277+
</script>

0 commit comments

Comments
 (0)