Skip to content

Commit 3eaaf5c

Browse files
Merge pull request #14 from felixgollnhuber/codex/fge-internationalisierung-hinzu
Add English internationalization support across app routes and UI
2 parents afe0a8f + b0e7cba commit 3eaaf5c

54 files changed

Lines changed: 2783 additions & 732 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.coolify.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
APP_SETUP_COMPLETE=false
22
APP_NAME=tempoll
33
APP_URL=https://tempoll.example.com
4+
APP_DEFAULT_LOCALE=de
45
LEGAL_PAGES_ENABLED=false
56

67
TEMPOLL_DB_NAME=tempoll

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ APP_SETUP_COMPLETE=false
22

33
APP_NAME="tempoll"
44
APP_URL="http://localhost:3000"
5+
APP_DEFAULT_LOCALE="de"
56
DATABASE_URL="postgresql://postgres:postgres@localhost:55432/tempoll?schema=public"
67
LEGAL_PAGES_ENABLED=false
78

proxy.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import type { NextRequest } from "next/server";
33

44
import { getParticipantSessionCookieFromEditLink } from "@/lib/event-service";
55
import { appConfig } from "@/lib/config";
6+
import { LOCALE_COOKIE_NAME } from "@/lib/i18n/locale";
7+
import { createI18n, resolveLocale } from "@/lib/i18n/server";
68
import { getParticipantCookieOptions } from "@/lib/tokens";
79
import { isAppSetupComplete } from "@/lib/setup-state";
810
import { PRIVATE_NO_STORE_HEADERS, mergeHeaders } from "@/lib/security";
@@ -17,6 +19,13 @@ function getEventSlug(pathname: string) {
1719
}
1820

1921
export async function proxy(request: NextRequest) {
22+
const i18n = createI18n(
23+
resolveLocale({
24+
cookieLocale: request.cookies.get(LOCALE_COOKIE_NAME)?.value,
25+
acceptLanguage: request.headers.get("accept-language"),
26+
}),
27+
);
28+
2029
if (!isAppSetupComplete()) {
2130
const { pathname } = request.nextUrl;
2231

@@ -27,7 +36,7 @@ export async function proxy(request: NextRequest) {
2736
if (pathname.startsWith("/api/")) {
2837
return NextResponse.json(
2938
{
30-
error: "App setup is not complete yet. Open /setup to generate the environment configuration.",
39+
error: i18n.messages.errors.appSetupIncomplete,
3140
},
3241
{ status: 503 },
3342
);

src/app/api/events/[slug]/availability/route.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ describe("PUT /api/events/[slug]/availability", () => {
4949
selectedSlotStarts: ["2026-03-30T07:00:00.000Z"],
5050
}),
5151
headers: {
52+
"Accept-Language": "en-US",
5253
"Content-Type": "application/json",
5354
},
5455
}),
@@ -61,6 +62,7 @@ describe("PUT /api/events/[slug]/availability", () => {
6162

6263
expect(saveAvailability).toHaveBeenCalledWith(
6364
"test-event",
65+
"en",
6466
{
6567
selectedSlotStarts: ["2026-03-30T07:00:00.000Z"],
6668
},
@@ -71,9 +73,7 @@ describe("PUT /api/events/[slug]/availability", () => {
7173
});
7274

7375
it("returns the domain-auth error for missing or expired participant sessions", async () => {
74-
saveAvailability.mockRejectedValue(
75-
unauthorized("Your editing session is no longer valid. Reopen your participant link or join the event again."),
76-
);
76+
saveAvailability.mockRejectedValue(unauthorized("participant_session_missing"));
7777

7878
const { PUT } = await import("./route");
7979
const response = await PUT(
@@ -83,6 +83,7 @@ describe("PUT /api/events/[slug]/availability", () => {
8383
selectedSlotStarts: [],
8484
}),
8585
headers: {
86+
"Accept-Language": "en-US",
8687
"Content-Type": "application/json",
8788
},
8889
}),
@@ -110,6 +111,7 @@ describe("PUT /api/events/[slug]/availability", () => {
110111
selectedSlotStarts: [],
111112
}),
112113
headers: {
114+
"Accept-Language": "en-US",
113115
"Content-Type": "application/json",
114116
},
115117
}),

src/app/api/events/[slug]/availability/route.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ import { cookies } from "next/headers";
22
import { NextResponse } from "next/server";
33

44
import { saveAvailability } from "@/lib/event-service";
5+
import { createI18n, getLocaleFromRequest } from "@/lib/i18n/server";
56
import { handleRouteError, PRIVATE_NO_STORE_HEADERS } from "@/lib/security";
67
import { getClientIp } from "@/lib/request";
78
import { enforceRateLimit } from "@/lib/rate-limit";
89
import { getParticipantCookieName } from "@/lib/tokens";
9-
import { availabilityMutationSchema } from "@/lib/validators";
10+
import { createAvailabilityMutationSchema } from "@/lib/validators";
1011

1112
type Context = {
1213
params: Promise<{
@@ -15,6 +16,8 @@ type Context = {
1516
};
1617

1718
export async function PUT(request: Request, { params }: Context) {
19+
const i18n = createI18n(getLocaleFromRequest(request));
20+
1821
try {
1922
const { slug } = await params;
2023
const ip = getClientIp(request);
@@ -24,19 +27,19 @@ export async function PUT(request: Request, { params }: Context) {
2427
enforceRateLimit(`availability:ip:${slug}:${ip}`, {
2528
limit: 120,
2629
windowMs: 5 * 60 * 1000,
27-
message: "Too many availability updates from this network. Please wait a moment and try again.",
30+
code: "availability_ip_rate_limited",
2831
});
2932

3033
enforceRateLimit(`availability:session:${slug}:${cookieValue ?? ip}`, {
3134
limit: 60,
3235
windowMs: 60 * 1000,
33-
message: "Too many availability updates in a short time. Please slow down for a moment.",
36+
code: "availability_session_rate_limited",
3437
});
3538

3639
const json = await request.json();
37-
const mutation = availabilityMutationSchema.parse(json);
40+
const mutation = createAvailabilityMutationSchema().parse(json);
3841

39-
const result = await saveAvailability(slug, mutation, cookieValue);
42+
const result = await saveAvailability(slug, i18n.locale, mutation, cookieValue);
4043

4144
return NextResponse.json(
4245
{ ok: true, snapshot: result.snapshot },
@@ -46,7 +49,8 @@ export async function PUT(request: Request, { params }: Context) {
4649
);
4750
} catch (error) {
4851
return handleRouteError(error, {
49-
fallbackMessage: "Unable to save availability.",
52+
fallbackMessage: i18n.messages.errors.routeFallbacks.saveAvailability,
53+
messages: i18n.messages,
5054
route: "api/events/[slug]/availability",
5155
headers: PRIVATE_NO_STORE_HEADERS,
5256
});

src/app/api/events/[slug]/ics/route.test.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,18 @@ describe("GET /api/events/[slug]/ics", () => {
3131
});
3232

3333
const { GET } = await import("./route");
34-
const response = await GET(new Request("https://tempoll.example.com/api/events/team-sync/ics"), {
35-
params: Promise.resolve({
36-
slug: "team-sync",
34+
const response = await GET(
35+
new Request("https://tempoll.example.com/api/events/team-sync/ics", {
36+
headers: {
37+
"Accept-Language": "en-US",
38+
},
3739
}),
38-
});
40+
{
41+
params: Promise.resolve({
42+
slug: "team-sync",
43+
}),
44+
},
45+
);
3946

4047
expect(response.status).toBe(200);
4148
expect(response.headers.get("content-type")).toContain("text/calendar");
@@ -60,11 +67,18 @@ describe("GET /api/events/[slug]/ics", () => {
6067
});
6168

6269
const { GET } = await import("./route");
63-
const response = await GET(new Request("https://tempoll.example.com/api/events/team-sync/ics"), {
64-
params: Promise.resolve({
65-
slug: "team-sync",
70+
const response = await GET(
71+
new Request("https://tempoll.example.com/api/events/team-sync/ics", {
72+
headers: {
73+
"Accept-Language": "en-US",
74+
},
6675
}),
67-
});
76+
{
77+
params: Promise.resolve({
78+
slug: "team-sync",
79+
}),
80+
},
81+
);
6882

6983
expect(response.status).toBe(404);
7084
await expect(response.json()).resolves.toEqual({

src/app/api/events/[slug]/ics/route.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { NextResponse } from "next/server";
22

33
import { getPublicEventSnapshot } from "@/lib/event-service";
4+
import { createI18n, getLocaleFromRequest } from "@/lib/i18n/server";
45
import { buildEventCalendarFile } from "@/lib/ics";
56
import { PUBLIC_NO_STORE_HEADERS, mergeHeaders } from "@/lib/security";
67
import { buildPublicEventUrl } from "@/lib/tokens";
@@ -13,13 +14,14 @@ type Context = {
1314

1415
export const dynamic = "force-dynamic";
1516

16-
export async function GET(_request: Request, { params }: Context) {
17+
export async function GET(request: Request, { params }: Context) {
18+
const i18n = createI18n(getLocaleFromRequest(request));
1719
const { slug } = await params;
18-
const event = await getPublicEventSnapshot(slug);
20+
const event = await getPublicEventSnapshot(slug, i18n.locale);
1921

2022
if (!event || event.snapshot.status !== "CLOSED" || !event.snapshot.finalizedSlot) {
2123
return NextResponse.json(
22-
{ error: "Event not found." },
24+
{ error: i18n.messages.errors.routeFallbacks.eventNotFound },
2325
{
2426
status: 404,
2527
headers: mergeHeaders(PUBLIC_NO_STORE_HEADERS),

src/app/api/events/[slug]/participants/route.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ import { NextResponse } from "next/server";
22

33
import { appConfig } from "@/lib/config";
44
import { joinParticipant } from "@/lib/event-service";
5+
import { createI18n, getLocaleFromRequest } from "@/lib/i18n/server";
56
import { handleRouteError, PRIVATE_NO_STORE_HEADERS } from "@/lib/security";
67
import { getClientIp } from "@/lib/request";
78
import { enforceRateLimit } from "@/lib/rate-limit";
89
import { getParticipantCookieOptions } from "@/lib/tokens";
9-
import { participantCreateSchema } from "@/lib/validators";
10+
import { createParticipantCreateSchema } from "@/lib/validators";
1011

1112
type Context = {
1213
params: Promise<{
@@ -15,18 +16,20 @@ type Context = {
1516
};
1617

1718
export async function POST(request: Request, { params }: Context) {
19+
const i18n = createI18n(getLocaleFromRequest(request));
20+
1821
try {
1922
const { slug } = await params;
2023
const ip = getClientIp(request);
2124

2225
enforceRateLimit(`event-join:${slug}:${ip}`, {
2326
limit: 20,
2427
windowMs: 10 * 60 * 1000,
25-
message: "Too many join attempts. Please wait a few minutes and try again.",
28+
code: "event_join_rate_limited",
2629
});
2730

2831
const json = await request.json();
29-
const input = participantCreateSchema.parse(json);
32+
const input = createParticipantCreateSchema(i18n.messages).parse(json);
3033
const result = await joinParticipant(slug, input.displayName);
3134

3235
const response = NextResponse.json(
@@ -48,7 +51,8 @@ export async function POST(request: Request, { params }: Context) {
4851
return response;
4952
} catch (error) {
5053
return handleRouteError(error, {
51-
fallbackMessage: "Unable to join event.",
54+
fallbackMessage: i18n.messages.errors.routeFallbacks.joinEvent,
55+
messages: i18n.messages,
5256
route: "api/events/[slug]/participants",
5357
headers: PRIVATE_NO_STORE_HEADERS,
5458
});

src/app/api/events/[slug]/route.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { cookies } from "next/headers";
22
import { NextResponse } from "next/server";
33

44
import { getPublicEventSnapshot } from "@/lib/event-service";
5+
import { createI18n, getLocaleFromRequest } from "@/lib/i18n/server";
56
import { PRIVATE_NO_STORE_HEADERS, PUBLIC_NO_STORE_HEADERS, mergeHeaders } from "@/lib/security";
67
import { getParticipantCookieName } from "@/lib/tokens";
78

@@ -13,14 +14,18 @@ type Context = {
1314

1415
export const dynamic = "force-dynamic";
1516

16-
export async function GET(_request: Request, { params }: Context) {
17+
export async function GET(request: Request, { params }: Context) {
18+
const i18n = createI18n(getLocaleFromRequest(request));
1719
const { slug } = await params;
1820
const cookieStore = await cookies();
1921
const cookieValue = cookieStore.get(getParticipantCookieName(slug))?.value;
20-
const event = await getPublicEventSnapshot(slug, cookieValue);
22+
const event = await getPublicEventSnapshot(slug, i18n.locale, cookieValue);
2123

2224
if (!event) {
23-
return NextResponse.json({ error: "Event not found." }, { status: 404 });
25+
return NextResponse.json(
26+
{ error: i18n.messages.errors.routeFallbacks.eventNotFound },
27+
{ status: 404 },
28+
);
2429
}
2530

2631
return NextResponse.json(

0 commit comments

Comments
 (0)