Compare commits

...

8 commits

Author SHA1 Message Date
07f0999a3d Fix docker 2024-03-20 01:13:05 +01:00
b1b91afab5 Refactor languages 2024-03-20 00:20:47 +01:00
0712c789d4 Refactor languages 2024-03-19 20:10:00 +01:00
1f4ca9a3b7 . 2024-03-19 19:33:58 +01:00
5ab3114c40 Fix 2024-03-05 11:55:24 +01:00
bfd0647493 . 2024-03-05 11:23:00 +01:00
3f31d24824 Update gender according to language 2024-03-05 11:23:00 +01:00
d403da0c9c Refactor 2024-03-05 11:23:00 +01:00
75 changed files with 512 additions and 309 deletions

View file

@ -10,7 +10,6 @@ RUN npm install
COPY . .
RUN npm run migrate
RUN npm run build
ENV HOST=0.0.0.0

View file

@ -4,8 +4,18 @@ services:
ports:
- "8000:8000"
environment:
DATABASE_PATH: /var/sqlite/reset-sender-v2.sqlite
DATABASE_URL: postgres://reset_sender_v2:reset_sender_v2@postgres:5432/reset_sender_v2
postgres:
image: postgres:14-alpine
volumes:
- sqlite_data:/var/sqlite
- postgres_data:/var/lib/postgresql/data/
environment:
POSTGRES_DB: reset_sender_v2
POSTGRES_USER: reset_sender_v2
POSTGRES_PASSWORD: reset_sender_v2
restart: unless-stopped
ports:
- '5432:5432'
volumes:
sqlite_data:
postgres_data:

View file

@ -1,7 +1,7 @@
import "dotenv/config";
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";
import { db, client } from "../src/db";
const migrationClient = postgres(process.env.DATABASE_URL!, { max: 1 });
migrate(drizzle(migrationClient), { migrationsFolder: "drizzle" });
await migrate(db, { migrationsFolder: "drizzle" });
await client.end();

View file

@ -1,5 +1,6 @@
name: DONAU Versicherung
lang: de
regions: [au]
template: de
emails:
- donau@donauversicherung.at
- j.havasi@donauversicherung.at

View file

@ -1,5 +1,6 @@
name: WIENER STÄDTISCHE
lang: de
regions: [au]
template: de
emails:
- kundenservice@wienerstaedtische.at
- r.mueller@wienerstaedtische.at

View file

@ -1,5 +1,6 @@
name: CPP
lang: cs
regions: [cz]
template: cs
emails:
- info@cpp.cz
- pavel.wiesner@cpp.cz

View file

@ -1,5 +1,6 @@
name: Kooperativa
lang: cs
regions: [cz]
template: cs
emails:
- info@koop.cz
- mdivis@koop.cz

View file

@ -1,4 +1,5 @@
name: Alfa
lang: hu
regions: [hu]
template: hu
emails:
- peter.zatyko@alfa.hu

View file

@ -1,5 +1,6 @@
name: Union Biztosito
lang: hu
regions: [hu]
template: hu
emails:
- info@union.hu
- gabor.havas@union.hu

View file

@ -1,5 +1,6 @@
name: Compensa
lang: lt
regions: [lt]
template: lt
emails:
- info@compensa.lt
- deividas.raipa@compensa.lt

View file

@ -1,5 +1,6 @@
name: Donaris
lang: ro
regions: [ro]
template: ro
emails:
- office@donaris.md
- d.gherasim@donaris.md

View file

@ -1,5 +1,6 @@
name: Compensa
lang: pl
regions: [pl]
template: pl
emails:
- prasa@compensa.pl
- anna.wlodarczyk-moczkowska@compensa.pl

View file

@ -1,5 +1,6 @@
name: Interrisk
lang: pl
regions: [pl]
template: pl
emails:
- sekretariat@interrisk.pl
- piotr.narloch@interrisk.pl

View file

@ -1,5 +1,6 @@
name: Wiener
lang: pl
regions: [pl]
template: pl
emails:
- kontakt@wiener.pl
- adwalcki@wiener.pl

View file

@ -1,5 +1,6 @@
name: Asirom
lang: ro
regions: [ro]
template: ro
emails:
- comunicare@asirom.ro
- madalin.rosu@asirom.ro

View file

@ -1,5 +1,6 @@
name: Omniasig
lang: ro
regions: [ro]
template: ro
emails:
- office@omniasig.ro
- mihai.tecau@omniasig.ro

View file

@ -1,5 +1,6 @@
name: Komunálna poisťovňa
lang: sk
regions: [sk]
template: sk
emails:
- sekretariatgr@kpas.sk
- slavka.miklosova@kpas.sk

View file

@ -1,5 +1,6 @@
name: Kooperativa
lang: sk
regions: [sk]
template: sk
emails:
- sekretariatgr.koop@koop.sk
- vladimirbakes@koop.sk

View file

@ -1,6 +1,6 @@
name: VIG (Holding)
lang: en
messageTemplate: vigHolding
regions: [intl, au]
template: vigHolding
ceo: Hartwig Löger
emails:
- hartwig.loeger@vig.com

View file

@ -1,5 +1,19 @@
import { z, defineCollection } from "astro:content";
import { LANGUAGES } from "../config";
const regions = defineCollection({
type: "data",
schema: z.object({
title: z.string(),
languages: z.array(z.string()),
}),
});
const languages = defineCollection({
type: "data",
schema: z.object({
title: z.string(),
}),
});
/**
* Branches are classified in subdirectories according to their country.
@ -10,16 +24,8 @@ const branches = defineCollection({
type: "data",
schema: z.object({
name: z.string(),
/**
* Determines the language of the letter. This can be different from the chosen page language.
*/
lang: z.string(),
/**
* Usually template is determined by language. In some special cases, however, it is useful
* to use some specific template per branch. In such case `messageTemplate` overrides `lang`.
*/
messageTemplate: z.string().optional(),
template: z.string(),
regions: z.array(z.string()),
emails: z.array(z.string()).nonempty(),
}),
});
@ -55,6 +61,8 @@ const emailConfirmed = defineCollection({
});
export const collections = {
regions,
languages,
branches,
variants,
intro,

View file

@ -0,0 +1 @@
title: Česky

View file

@ -0,0 +1 @@
title: Deutsch

View file

@ -0,0 +1 @@
title: English

View file

@ -0,0 +1 @@
title: Magyar

View file

@ -0,0 +1 @@
title: Lietuviškas

View file

@ -0,0 +1 @@
title: Polski

View file

@ -0,0 +1 @@
title: Românesc

View file

@ -0,0 +1 @@
title: Slovensky

View file

@ -0,0 +1,2 @@
title: Austria
languages: [de]

View file

@ -0,0 +1,2 @@
title: Czechia
languages: [cs]

View file

@ -0,0 +1,2 @@
title: Hungary
languages: [hu]

View file

@ -0,0 +1,2 @@
title: International
languages: [en]

View file

@ -0,0 +1,2 @@
title: Lithuania
languages: [lt]

View file

@ -0,0 +1,2 @@
title: Moldova
languages: [ro]

View file

@ -0,0 +1,2 @@
title: Poland
languages: [pl]

View file

@ -0,0 +1,2 @@
title: Romania
languages: [ro]

View file

@ -0,0 +1,2 @@
title: Slovakia
languages: [sk]

View file

@ -1,12 +0,0 @@
import type { Lang } from "./lang";
export const countries: Record<Lang, string> = {
en: "International (english)",
cs: "Czechia",
sk: "Slovakia",
pl: "Poland",
hu: "Hungary",
ro: "Romania/Moldova",
lt: "Lithuania",
de: "Austria",
};

View file

@ -2,5 +2,11 @@ import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
const client = postgres(import.meta.env.DATABASE_URL);
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error("'DATABASE_URL' environment variable not set");
}
export const client = postgres(DATABASE_URL);
export const db = drizzle(client, { schema });

View file

@ -1,7 +1,32 @@
import { z } from "zod";
import { LANGUAGES } from "./config.ts";
export type Lang = (typeof LANGUAGES)[number];
export function isKnownLang(str: string): str is Lang {
return (LANGUAGES as ReadonlyArray<string>).includes(str);
export const langSchema = z.union(LANGUAGES.map((lang) => z.literal(lang)));
export class UnknownLanguageError extends Error {
constructor(lang: string | undefined) {
super(`Unknown language '${lang}'`);
}
}
export function tryGetWebsiteLang(params: Record<string, string | undefined>): Lang | undefined {
const { lang } = params;
if (lang && !isKnownLang(lang)) {
throw new UnknownLanguageError(lang);
}
return lang as Lang;
}
export function getWebsiteLang(params: Record<string, string | undefined>): Lang {
const { lang } = params;
if (!isKnownLang(lang)) {
throw new UnknownLanguageError(lang);
}
return lang;
}
export function isKnownLang(str: unknown): str is Lang {
return typeof str === "string" && (LANGUAGES as ReadonlyArray<string>).includes(str);
}

View file

@ -1,14 +1,14 @@
---
import { DEFAULT_LANG } from "../config";
import { makeT } from "../i18n";
import type { Lang } from "../lang";
import { tryGetWebsiteLang, type Lang } from "../lang";
import Footer from "../views/layout/Footer.astro";
interface Props {
lang?: Lang;
}
const { lang = DEFAULT_LANG } = Astro.props;
const lang = tryGetWebsiteLang(Astro.params) ?? DEFAULT_LANG;
const { t } = makeT(lang);
const title = t("website_name");

View file

@ -0,0 +1,48 @@
---
import { makeT } from "../i18n";
import type { Lang } from "../lang";
import Radio from "../ui/Radio.astro";
import RadioGroup from "../ui/RadioGroup.astro";
import type { Options } from "./request";
interface Props {
websiteLang: Lang;
letterLang: Lang;
options: Options;
}
const { websiteLang, options, ...props } = Astro.props;
const { t } = makeT(websiteLang);
const genderOptions: Record<Lang, Array<"f" | "n" | "m">> = {
en: [],
cs: ["f", "m", "n"],
sk: ["m", "f", "n"],
pl: ["m", "f", "n"],
hu: [],
ro: ["m", "f", "n"],
lt: ["m", "f", "n"],
de: ["m", "f", "n"],
};
const availableGenders = genderOptions[props.letterLang];
---
<RadioGroup
required
id="gender-input"
label={t("form.gender")}
class:list={{ hidden: !availableGenders || availableGenders.length === 0 }}
{...props}
>
{
availableGenders.map((gender) => (
<Radio
name="gender"
value={gender}
label={t(`form.gender.${gender}`)}
checked={options.gender === gender}
/>
))
}
</RadioGroup>

View file

@ -1,40 +1,37 @@
---
import { makeT } from "../i18n";
import { makeT, type TranslationKey } from "../i18n";
import Button from "../ui/Button.astro";
import { letterOptionsSchema, type LetterOptions } from "./schema";
import LoadingIndicator from "./LoadingIndicator.astro";
import { renderText } from "./templates";
import type { Lang } from "../lang";
import type { Options } from "./request";
import { optionsValidationSchema } from "./schema";
interface Props {
lang: Lang;
options?: Partial<LetterOptions>;
defaultBranchId: string;
websiteLang: Lang;
letterLang: Lang;
options: Options;
message: string;
}
const { lang, options = {}, defaultBranchId } = Astro.props;
const { t } = makeT(lang);
const result = letterOptionsSchema.safeParse(options);
const letterText = renderText(lang, {
...options,
branch: options?.branch ?? defaultBranchId,
});
const { websiteLang, letterLang, options, message, ...props } = Astro.props;
const { t } = makeT(websiteLang);
const validationResult = await optionsValidationSchema.safeParseAsync(options);
---
<form id="letter-body">
<form id="letter-body" {...props}>
<div class="mb-6">
<label class="block text-base mb-2" for="letter-input">
{t("form.message")}
</label>
<div class="relative overflow-hidden rounded-[3px]">
<textarea
lang={letterLang}
name="message"
id="letter-input"
cols={60}
rows={24}
class="block w-full px-4 py-3 bg-white text-black text-lg"
style="white-space: pre-wrap">{letterText}</textarea
style="white-space: pre-wrap">{message}</textarea
>
<LoadingIndicator
id="letter-progress-bar"
@ -42,11 +39,11 @@ const letterText = renderText(lang, {
/>
</div>
</div>
<div>
<div class="flex flex-wrap items-center gap-4">
<Button
class="flex w-full md:w-auto md:mx-auto lg:mx-0 md:min-w-[260px]"
type="button"
disabled={!result.success}
disabled={!validationResult.success}
onclick="document.querySelector('#contact-details-dialog')?.showModal()"
>
{t("form.send_letter")}
@ -56,5 +53,13 @@ const letterText = renderText(lang, {
></path>
</svg>
</Button>
{
!validationResult.success && (
<p>
{t(`form.${validationResult.error.issues[0].path}` as TranslationKey)}
{validationResult.error.issues[0].message}
</p>
)
}
</div>
</form>

View file

@ -0,0 +1,25 @@
---
import Select from "../ui/Select.astro";
import type { Lang } from "../lang";
import { type BranchOption } from "./branches";
interface Props {
branchOptions: Array<BranchOption>;
branchId?: string;
}
const { branchOptions, branchId } = Astro.props;
---
<Select name="branch" id="branch-input" class="mb-6">
{
branchOptions.map((branch, index) => {
const selected = branchId ? branch.id === branchId : index === 0;
return (
<option value={branch.id} selected={selected}>
{branch.name} ({branch.region})
</option>
);
})
}
</Select>

View file

@ -4,35 +4,39 @@ import LetterOptionsForm from "./LetterOptionsForm.astro";
import LetterVariantInput from "./LetterVariantInput.astro";
import LetterContactDetails from "./LetterContactDetails.astro";
import type { Lang } from "../lang";
import { getBranches } from "./branches";
import type { Options } from "./request";
import { getMessage } from "./templates";
import { getBranchOptions, type BranchOption } from "./branches";
import { getEntry } from "astro:content";
interface Props {
lang: Lang;
websiteLang: Lang;
options: Options;
}
const { lang } = Astro.props;
const branches = await getBranches(lang);
const defaultBranchId = branches[0].id;
const { websiteLang, options } = Astro.props;
const branchOptions = await getBranchOptions(websiteLang);
const selectedBranch = (await getEntry("branches", options.branchId ?? branchOptions[0].id))!;
const { message, letterLang } = getMessage({ websiteLang, options, selectedBranch });
---
<div
id="letter-builder"
hx-get={`/${lang}/letter`}
hx-get={`/${websiteLang}/letter`}
hx-trigger="change"
hx-target="#letter-body"
hx-include="#letter-variant-input [name], #letter-options [name]"
hx-indicator="#letter-progress-bar"
>
<div class="bg-black/50 px-6 py-6 -mx-6 rounded-t theme-inverted">
<LetterVariantInput {lang} />
<LetterVariantInput {websiteLang} />
</div>
<div class="grid lg:grid-cols-3 items-start gap-12 bg-black/10 px-6 py-6 -mx-6 rounded-b mb-12">
<LetterOptionsForm {lang} {branches} {defaultBranchId} />
<LetterOptionsForm {websiteLang} {letterLang} {branchOptions} {options} />
<div class="lg:col-span-2">
<LetterBodyForm {lang} {defaultBranchId} />
<LetterBodyForm {message} {websiteLang} {letterLang} {options} />
</div>
</div>
</div>
<LetterContactDetails {lang} />
<LetterContactDetails {websiteLang} />

View file

@ -8,17 +8,17 @@ import Field from "../ui/Field.astro";
import LoadingIndicator from "./LoadingIndicator.astro";
interface Props {
lang: Lang;
websiteLang: Lang;
}
const { lang } = Astro.props;
const { t, jt } = makeT(lang);
const { websiteLang } = Astro.props;
const { t, jt } = makeT(websiteLang);
---
<Dialog id="contact-details-dialog" heading={t("form.few_more_details")}>
<LoadingIndicator slot="top" id="save-progress-bar" class="sticky top-0 left-0 right-0" />
<form
hx-post={`/${lang}/save`}
hx-post={`/${websiteLang}/save`}
hx-include="#letter-variant-input [name], #letter-options [name], #letter-body [name]"
hx-target="#contact-details-dialog [data-dialog-content]"
hx-indicator="#save-progress-bar"
@ -32,7 +32,7 @@ const { t, jt } = makeT(lang);
jt(
"form.agree_with_privacy_policy",
// prettier-ignore
<a href="/gdpr" target="_blank" class="link">{t("form.privacy_policy")}</a>,
<a href="/gdpr" target="_blank" class="link">{t("form.privacy_policy")}</a>
)
}
</Checkbox>

View file

@ -1,75 +1,37 @@
---
import type { CollectionEntry } from "astro:content";
import Field from "../ui/Field.astro";
import Radio from "../ui/Radio.astro";
import RadioGroup from "../ui/RadioGroup.astro";
import { makeT } from "../i18n";
import Label from "../ui/Label.astro";
import Select from "../ui/Select.astro";
import type { Lang } from "../lang";
import Checkbox from "../ui/Checkbox.astro";
import { countries } from "../countries";
import type { Options } from "./request";
import GenderInput from "./GenderInput.astro";
import LetterBranchInput from "./LetterBranchInput.astro";
import type { BranchOption } from "./branches";
interface Props {
lang: Lang;
branches: Array<CollectionEntry<"branches">>;
defaultBranchId: string;
websiteLang: Lang;
letterLang: Lang;
branchOptions: Array<BranchOption>;
options: Options;
}
const { lang, branches, defaultBranchId } = Astro.props;
const { t } = makeT(lang);
const genderOptions: Record<Lang, Array<"f" | "n" | "m">> = {
en: [],
cs: ["f", "m", "n"],
sk: ["m", "f", "n"],
pl: ["m", "f", "n"],
hu: [],
ro: ["m", "f", "n"],
lt: ["m", "f", "n"],
de: ["m", "f", "n"],
};
const genders = genderOptions[lang];
const { websiteLang, letterLang, branchOptions, options } = Astro.props;
const { t } = makeT(websiteLang);
---
<form id="letter-options" class="grid md:grid-cols-2 lg:grid-cols-1 gap-8" novalidate>
<Field required label={t("form.first_name")} name="firstName" />
<Field required label={t("form.last_name")} name="lastName" />
<Field required label={t("form.first_name")} name="firstName" value={options.firstName} />
<Field required label={t("form.last_name")} name="lastName" value={options.lastName} />
<div>
<Label required class="mb-2" for="branch-input">
{t("form.branch")}
</Label>
<Select name="branch" id="branch-input" class="mb-6">
{
branches.map((branch) => {
return (
<option value={branch.id} selected={branch.id === defaultBranchId}>
{branch.data.name}
{lang === "en" ? <span> {countries[branch.data.lang as Lang]}</span> : null}
</option>
);
})
}
</Select>
<LetterBranchInput {branchOptions} branchId={options.branchId} />
<Checkbox name="isClient">
{t("form.is_client")}
</Checkbox>
</div>
{
genders && genders.length > 0 && (
<RadioGroup required label={t("form.gender")}>
{genders.map((gender, index) => (
<Radio
name="gender"
value={gender}
label={t(`form.gender.${gender}`)}
checked={index === 0}
/>
))}
</RadioGroup>
)
}
<GenderInput {websiteLang} {letterLang} {options} />
</form>

View file

@ -5,12 +5,12 @@ import type { Lang } from "../lang";
import Dialog from "../ui/Dialog.astro";
interface Props {
lang: Lang;
websiteLang: Lang;
variant: CollectionEntry<"variants">;
}
const { lang, variant } = Astro.props;
const { t } = makeT(lang);
const { websiteLang, variant } = Astro.props;
const { t } = makeT(websiteLang);
const { id } = variant.data;
const { Content } = await variant.render();
const slug = variant.slug.split("/")[1];

View file

@ -5,14 +5,14 @@ import type { Lang } from "../lang";
import LetterVariantButton from "./LetterVariantButton.astro";
interface Props {
lang: Lang;
websiteLang: Lang;
}
const { lang } = Astro.props;
const { t } = makeT(lang);
const { websiteLang } = Astro.props;
const { t } = makeT(websiteLang);
const variants = (
await getCollection("variants", (variant) => variant.id.startsWith(`${lang}/`))
await getCollection("variants", (variant) => variant.id.startsWith(`${websiteLang}/`))
).toSorted((a, b) => a.data.id - b.data.id);
---
@ -22,7 +22,11 @@ const variants = (
{t("form.variant")}
</legend>
<div class="grid md:grid-cols-3 gap-2">
{variants.map((variant) => <LetterVariantButton lang={lang} variant={variant} />)}
{
variants.map((variant) => (
<LetterVariantButton websiteLang={websiteLang} variant={variant} />
))
}
</div>
</fieldset>
</form>

View file

@ -1,17 +1,29 @@
import { getCollection } from "astro:content";
import { getCollection, getEntry } from "astro:content";
import type { Lang } from "../lang";
export async function getBranches(lang: Lang) {
// For international version, we list all branches, but display letter in english
if (lang === "en") {
const branches = await getCollection("branches");
return branches.sort((a, b) => a.data.name.localeCompare(b.data.name));
}
const branches = await getCollection(
"branches",
(branch) => branch.data.lang === "en" || branch.data.lang === lang
);
return branches.sort((a, b) => a.data.name.localeCompare(b.data.name));
export interface BranchOption {
id: string;
name: string;
region: string;
}
export async function getBranchOptions(websiteLang: Lang): Promise<Array<BranchOption>> {
const allRegions = await getCollection("regions");
const allBranches = await getCollection("branches");
return allRegions
.filter(
(region) =>
websiteLang === "en" || region.id === "intl" || region.data.languages.includes(websiteLang)
)
.flatMap((region) => {
const regionalBranches = allBranches.filter((b) => b.data.regions.includes(region.id));
return regionalBranches.map((branch) => {
return {
id: branch.id,
name: branch.data.name,
region: region.data.title,
};
});
});
}

29
src/letter/request.ts Normal file
View file

@ -0,0 +1,29 @@
import { z } from "zod";
import type { Lang } from "../lang";
import { optionsSchema } from "./schema";
export type Options = z.TypeOf<typeof optionsSchema>;
export async function parseOptionsFromRequest(
params: URLSearchParams
): Promise<{ status: "notFound" } | { status: "ok"; options: Options }> {
const options = {
firstName: params.get("firstName") || undefined,
lastName: params.get("lastName") || undefined,
variant: params.get("variant") || undefined,
gender: params.get("gender") || undefined,
branchId: params.get("branch") || undefined,
isClient: params.has("isClient"),
};
const result = await optionsSchema.safeParseAsync(options);
if (!result.success) {
return { status: "notFound" };
}
const parsed = result.data;
return {
status: "ok",
options: parsed,
};
}

View file

@ -1,22 +1,22 @@
import { z } from "zod";
import { checkbox } from "../lib/types";
export const letterOptionsSchema = z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
variant: z.string().min(1),
gender: z.string().min(1).optional(),
branch: z.string().min(1),
isClient: checkbox(),
export const variantSchema = z.union([z.literal("1"), z.literal("2"), z.literal("3")]);
export const genderSchema = z.union([z.literal("m"), z.literal("f"), z.literal("n")]);
export const optionsSchema = z.object({
firstName: z.string().min(1).optional(),
lastName: z.string().min(1).optional(),
variant: variantSchema.default("1"),
gender: genderSchema.default("m"),
branchId: z.string().min(1).optional(),
isClient: z.boolean(),
});
export type LetterOptions = z.TypeOf<typeof letterOptionsSchema>;
export const optionsValidationSchema = optionsSchema.required();
export const letterFormSchema = letterOptionsSchema.extend({
export const finalValidationSchema = optionsValidationSchema.extend({
email: z.string().min(1),
phone: z.string().optional(),
message: z.string().min(1),
subscribed: checkbox(),
message: z.string().min(10),
subscribed: z.boolean(),
});
export type LetterForm = z.TypeOf<typeof letterFormSchema>;

View file

@ -1,9 +1,8 @@
import type { TemplateFn } from "./types";
/**
* @status done
*/
export const cs: TemplateFn = (c) => {
export const lang = "cs";
export const render: TemplateFn = (c) => {
// prettier-ignore
return (
`Dobrý den,

View file

@ -1,9 +1,8 @@
import type { TemplateFn } from "./types";
/**
* @status done
*/
export const de: TemplateFn = (c) => {
export const lang = "de";
export const render: TemplateFn = (c) => {
// prettier-ignore
return (
`Guten Tag,

View file

@ -1,9 +1,8 @@
import type { TemplateFn } from "./types";
/**
* @status done
*/
export const en: TemplateFn = (c) => {
export const lang = "en";
export const render: TemplateFn = (c) => {
// prettier-ignore
return (
`Hello,

View file

@ -1,10 +1,8 @@
import type { TemplateFn } from "./types";
/**
* TODO: fix gender in opening
* @status done
*/
export const hu: TemplateFn = (c) => {
export const lang = "hu";
export const render: TemplateFn = (c) => {
// prettier-ignore
return (
`Tisztelt Hölgyem,

View file

@ -1,56 +1,66 @@
import type { LetterOptions } from "../schema";
import type { TemplateFn } from "./types";
import { getEntry, type CollectionEntry } from "astro:content";
import type { Template, TemplateFn } from "./types";
import { type CollectionEntry } from "astro:content";
import { vigHolding } from "./vig-holding";
import { cs } from "./cs";
import { de } from "./de";
import { en } from "./en";
import { hu } from "./hu";
import { lt } from "./lt";
import { pl } from "./pl";
import { ro } from "./ro";
import { sk } from "./sk";
import * as vigHolding from "./vig-holding";
import * as cs from "./cs";
import * as de from "./de";
import * as en from "./en";
import * as hu from "./hu";
import * as lt from "./lt";
import * as pl from "./pl";
import * as ro from "./ro";
import * as sk from "./sk";
import type { Lang } from "../../lang";
import type { Options } from "../request";
const templates: Record<string, TemplateFn> = { vigHolding, cs, de, en, hu, lt, pl, ro, sk };
const templates: Record<string, Template> = {
vigHolding,
cs,
de,
en,
hu,
lt,
pl,
ro,
sk,
};
function getName({ firstName, lastName }: { firstName?: string; lastName?: string }) {
return [firstName, lastName].filter(Boolean).join(" ");
}
function getTemplate(websiteLang: Lang, branch: CollectionEntry<"branches">): TemplateFn {
// If website is in english (= international version), always use english translation of the letter
// If using other language version of the website, use associated language of the branch as letter language
const letterLang = websiteLang === "en" ? "en" : branch.data.lang;
return templates[branch.data.messageTemplate ?? letterLang] ?? en;
export function getTemplate(
websiteLang: Lang,
branch: CollectionEntry<"branches">
): { render: TemplateFn; letterLang: Lang } {
const { template: templateId } = branch.data;
const template = templates[templateId];
if (!template) {
throw new Error(`Unknown template '${templateId}'`);
}
const { render, lang } = template;
return { render, letterLang: lang };
}
export async function renderText(
websiteLang: Lang,
{
firstName,
lastName,
variant = "1",
gender = "f",
branch: branchId = "vig-holding",
isClient = false,
}: Partial<LetterOptions>
) {
const branch = await getEntry("branches", branchId);
if (!branch) {
throw new Error(`Unknown branch of id ${branchId}`);
}
export function getMessage({
websiteLang,
options,
selectedBranch,
}: {
websiteLang: Lang;
options: Options;
selectedBranch: CollectionEntry<"branches">;
}) {
const { render, letterLang } = getTemplate(websiteLang, selectedBranch);
const { firstName, lastName, variant, gender, isClient } = options;
const name = getName({ firstName, lastName });
const template = getTemplate(websiteLang, branch);
return template({
const message = render({
name,
variant,
branch: branch.data.name,
branchId,
branchId: selectedBranch.id,
branch: selectedBranch.data.name,
gender,
isClient,
});
return { letterLang, message };
}

View file

@ -1,9 +1,8 @@
import type { TemplateFn } from "./types";
/**
* @status done
*/
export const lt: TemplateFn = (c) => {
export const lang = "lt";
export const render: TemplateFn = (c) => {
// prettier-ignore
return (
`Sveiki,

View file

@ -1,9 +1,8 @@
import type { TemplateFn } from "./types";
/**
* @status done
*/
export const pl: TemplateFn = (c) => {
export const lang = "pl";
export const render: TemplateFn = (c) => {
// prettier-ignore
return (
`Cześć!

View file

@ -1,6 +1,8 @@
import type { TemplateFn } from "./types";
export const ro: TemplateFn = (c) => {
export const lang = "ro";
export const render: TemplateFn = (c) => {
// prettier-ignore
return (
`Bună ziua,

View file

@ -1,9 +1,8 @@
import type { TemplateFn } from "./types";
/**
* @status done
*/
export const sk: TemplateFn = (c) => {
export const lang = "sk";
export const render: TemplateFn = (c) => {
// prettier-ignore
return (
`Dobrý deň

View file

@ -1,3 +1,10 @@
import type { Lang } from "../../lang";
export interface Template {
lang: Lang;
render: TemplateFn;
}
export type TemplateFn = (context: TemplateContext) => string;
export type TemplateContext = {

View file

@ -1,9 +1,8 @@
import type { TemplateFn } from "./types";
/**
* @status done
*/
export const vigHolding: TemplateFn = (c) => {
export const lang = "en";
export const render: TemplateFn = (c) => {
// prettier-ignore
return (
`Dear M. Löger,

View file

@ -1,4 +0,0 @@
import { z } from "zod";
// Convert key presence or absence in form data to boolean
export const checkbox = () => z.unknown().transform((value) => value !== undefined);

View file

@ -1,14 +1,14 @@
import "dotenv/config";
import type { MiddlewareHandler } from "astro";
import { isKnownLang } from "./lang";
import { UnknownLanguageError } from "./lang";
export const onRequest: MiddlewareHandler = async ({ params }, next) => {
const lang = params.lang;
// Unknown language returns 404
if (lang && !isKnownLang(lang)) {
return new Response(null, { status: 404, statusText: "Not found" });
try {
return await next();
} catch (error) {
if (error instanceof UnknownLanguageError) {
return new Response(null, { status: 404, statusText: "Not found" });
}
throw error;
}
return await next();
};

View file

@ -1,12 +1,14 @@
---
import { getEntry } from "astro:content";
import Layout from "../../layouts/Layout.astro";
import { getWebsiteLang } from "../../lang";
export { getStaticPaths } from "../../static-paths";
const { lang } = Astro.params;
const websiteLang = getWebsiteLang(Astro.params);
const entry = (await getEntry("emailConfirmed", lang)) ?? (await getEntry("emailConfirmed", "en"));
const entry =
(await getEntry("emailConfirmed", websiteLang)) ?? (await getEntry("emailConfirmed", "en"));
const { Content } = await entry.render();
---

View file

@ -1,16 +1,28 @@
---
import { Image } from "astro:assets";
import { getEntry } from "astro:content";
import Layout from "../../layouts/Layout.astro";
import imgLogoVig from "../../assets/images/vig-logo.webp";
import imgBanknotes from "../../assets/images/banknotes.webp";
import { getEntry } from "astro:content";
import LetterBuilder from "../../letter/LetterBuilder.astro";
import { parseOptionsFromRequest } from "../../letter/request";
import { getWebsiteLang } from "../../lang";
export { getStaticPaths } from "../../static-paths";
const { lang } = Astro.params;
const websiteLang = getWebsiteLang(Astro.params);
const entry = (await getEntry("intro", lang)) ?? (await getEntry("intro", "en"));
// Get options
const result = await parseOptionsFromRequest(Astro.url.searchParams);
if (result.status === "notFound") {
return new Response(null, { status: 404 });
}
const { options } = result;
// Get intro text
const entry = (await getEntry("intro", websiteLang)) ?? (await getEntry("intro", "en"));
const { Content } = await entry.render();
---
@ -47,10 +59,6 @@ const { Content } = await entry.render();
</div>
</div>
</header>
<div class="text-center text-3xl font-display font-bold mt-16">
<p>Collection of letters has ended!</p>
<p>Thank you</p>
</div>
<!-- <LetterBuilder lang={lang} /> -->
<LetterBuilder {websiteLang} {options} />
</div>
</Layout>

View file

@ -1,12 +1,13 @@
---
import SubmitResponseLayout from "../../letter/SubmitResponseLayout.astro";
import { makeT } from "../../i18n";
import { getWebsiteLang } from "../../lang";
export { getStaticPaths } from "../../static-paths";
export const partial = true;
const { lang } = Astro.params;
const { t } = makeT(lang);
const websiteLang = getWebsiteLang(Astro.params);
const { t } = makeT(websiteLang);
---
<SubmitResponseLayout

View file

@ -1,12 +1,13 @@
---
import SubmitResponseLayout from "../../letter/SubmitResponseLayout.astro";
import { makeT } from "../../i18n";
import { getWebsiteLang } from "../../lang";
export { getStaticPaths } from "../../static-paths";
export const partial = true;
const { lang } = Astro.params;
const { t } = makeT(lang);
const websiteLang = getWebsiteLang(Astro.params);
const { t } = makeT(websiteLang);
---
<SubmitResponseLayout

View file

@ -1,28 +1,27 @@
---
import { z } from "zod";
import type { Lang } from "../../lang";
import { getEntry } from "astro:content";
import { getWebsiteLang, type Lang } from "../../lang";
import GenderInput from "../../letter/GenderInput.astro";
import LetterBodyForm from "../../letter/LetterBodyForm.astro";
import { checkbox } from "../../lib/types";
import { parseOptionsFromRequest } from "../../letter/request";
import { getMessage } from "../../letter/templates";
import { getBranchOptions } from "../../letter/branches";
export const prerender = false;
export const partial = true;
interface Params {
lang: Lang;
const websiteLang = getWebsiteLang(Astro.params);
const result = await parseOptionsFromRequest(Astro.url.searchParams);
if (result.status === "notFound") {
return new Response(null, { status: 404 });
}
export const requestSchema = z.object({
firstName: z.string().optional(),
lastName: z.string().optional(),
variant: z.string().optional(),
gender: z.string().optional(),
branch: z.string().optional(),
isClient: checkbox(),
});
const { lang } = Astro.params as unknown as Params;
const params = Astro.url.searchParams;
const options = requestSchema.parse(Object.fromEntries(params.entries()));
const { options } = result;
const branchOptions = await getBranchOptions(websiteLang);
const selectedBranch = (await getEntry("branches", options.branchId ?? branchOptions[0].id))!;
const { message, letterLang } = getMessage({ websiteLang, options, selectedBranch });
---
<LetterBodyForm lang={lang} options={options} />
<LetterBodyForm hx-swap-oob {websiteLang} {letterLang} {message} {options} />
<GenderInput hx-swap-oob {websiteLang} {letterLang} {options} />

View file

@ -1,12 +1,12 @@
import type { APIContext } from "astro";
import type { Lang } from "../../lang";
import { letterFormSchema } from "../../letter/schema";
import { db } from "../../db";
import * as schema from "../../schema";
import { generateToken } from "../../utils";
import { mailClient } from "../../mail";
import { renderMail } from "../../mails/confirm-email";
import { makeT } from "../../i18n";
import { finalValidationSchema } from "../../letter/schema";
import { and, eq } from "drizzle-orm";
export const prerender = false;
@ -16,9 +16,17 @@ interface Params extends Record<string, string> {
}
export async function POST({ request, params: { lang }, redirect }: APIContext<never, Params>) {
const formData = await request.formData();
const validationResult = await letterFormSchema.safeParseAsync({
...Object.fromEntries(formData.entries()),
const form = await request.formData();
const validationResult = await finalValidationSchema.safeParseAsync({
firstName: form.get("firstName"),
lastName: form.get("lastName"),
variant: form.get("variant"),
gender: form.get("gender"),
branchId: form.get("branch"),
isClient: form.has("isClient"),
email: form.get("email"),
message: form.get("message"),
subscribed: form.has("subscribed"),
});
if (!validationResult.success) {
@ -31,7 +39,7 @@ export async function POST({ request, params: { lang }, redirect }: APIContext<n
const { data } = validationResult;
const existingLetter = await db.query.letters.findFirst({
where: and(eq(schema.letters.email, data.email), eq(schema.letters.branch, data.branch)),
where: and(eq(schema.letters.email, data.email), eq(schema.letters.branch, data.branchId)),
});
if (existingLetter) {
@ -40,6 +48,7 @@ export async function POST({ request, params: { lang }, redirect }: APIContext<n
const values = {
...data,
branch: data.branchId,
confirmationToken: generateToken(32),
updated: new Date(),
phone: data.phone ?? "",

View file

@ -3,11 +3,13 @@ import { Image } from "astro:assets";
import { LANGUAGES } from "../config";
import imgLogoVig from "../assets/images/vig-logo.webp";
import Layout from "../layouts/Layout.astro";
import { countries } from "../countries";
import { getCollection } from "astro:content";
import { getEntry } from "astro:content";
const localeVariants = LANGUAGES.map((lang) => ({ lang, country: countries[lang] })).toSorted(
(a, b) => (b.lang === "en" ? Infinity : a.country.localeCompare(b.country)),
const regions = (await getCollection("regions")).toSorted((a, b) =>
b.id === "intl" ? Infinity : a.id.localeCompare(b.id)
);
const languages = await getCollection("languages");
---
<Layout>
@ -21,7 +23,7 @@ const localeVariants = LANGUAGES.map((lang) => ({ lang, country: countries[lang]
<h2 class="mb-6 font-bold">Choose your country</h2>
<ul class="grid md:grid-cols-2 gap-x-6 gap-y-4">
{
localeVariants.map(({ lang, country }) => (
regions.map((region) => (
<li class="flex gap-2">
<svg
class="w-6"
@ -31,9 +33,15 @@ const localeVariants = LANGUAGES.map((lang) => ({ lang, country: countries[lang]
>
<path d="M16.1716 10.9999L10.8076 5.63589L12.2218 4.22168L20 11.9999L12.2218 19.778L10.8076 18.3638L16.1716 12.9999H4V10.9999H16.1716Z" />
</svg>
<a class="link" hreflang={lang} href={`/${lang}`}>
{country}
</a>
<span>{region.data.title}</span>
{region.data.languages.map((lang) => {
const { title } = languages.find((l) => l.id === lang)?.data ?? {};
return (
<a class="link" hreflang={lang} href={`/${lang}`}>
{title}
</a>
);
})}
</li>
))
}

View file

@ -27,4 +27,5 @@ export const messages = pgTable("messages", {
from: text("from").notNull(),
to: text("to").notNull(),
status: statusEnum("status").notNull(),
foo: text("foo"),
});

View file

@ -1,5 +1,7 @@
---
interface Props {
import type { HTMLAttributes } from "astro/types";
interface Props extends HTMLAttributes<"fieldset"> {
required?: boolean;
label: string;
}
@ -7,7 +9,7 @@ interface Props {
const { label, required = false, ...props } = Astro.props;
---
<fieldset {...props}>
<fieldset {...props} hx-swap-oob>
<legend class="mb-3 text-base">
{label}
{required && <span class="required-tag">*</span>}

View file

@ -2,11 +2,11 @@
import { Image } from "astro:assets";
import imgSausages from "../../assets/images/spekacky.webp";
import { makeT } from "../../i18n";
import type { Lang } from "../../lang";
import { tryGetWebsiteLang, type Lang } from "../../lang";
import LogoDirtyMoney from "../logos/LogoDirtyMoney.astro";
import { DEFAULT_LANG } from "../../config";
const { lang } = Astro.params;
const lang = tryGetWebsiteLang(Astro.params) ?? DEFAULT_LANG;
const { t } = makeT(lang as Lang);
---