@@ -131,16 +118,8 @@ function publishedAt(g: { publishedAt?: string }): string | null {
{/each}
diff --git a/frontend/src/routes/geschichten/new/+page.server.ts b/frontend/src/routes/geschichten/new/+page.server.ts
index 4763555d..652aa86c 100644
--- a/frontend/src/routes/geschichten/new/+page.server.ts
+++ b/frontend/src/routes/geschichten/new/+page.server.ts
@@ -19,5 +19,12 @@ export const load: PageServerLoad = async ({ url, fetch, parent }) => {
const initialPersons =
personResult && personResult.response.ok && personResult.data ? [personResult.data] : [];
- return { initialPersons };
+ // Validate ?type against the known union — prevents unexpected strings from reaching the API.
+ // Security note: strict equality rejects encoded variants (e.g. STORY%00JOURNEY) and
+ // only the FIRST value is returned by searchParams.get() on repeated params.
+ const rawType = url.searchParams.get('type');
+ const selectedType: 'STORY' | 'JOURNEY' | null =
+ rawType === 'STORY' || rawType === 'JOURNEY' ? rawType : null;
+
+ return { initialPersons, selectedType };
};
diff --git a/frontend/src/routes/geschichten/new/+page.svelte b/frontend/src/routes/geschichten/new/+page.svelte
index ad80d87b..0eff0a68 100644
--- a/frontend/src/routes/geschichten/new/+page.svelte
+++ b/frontend/src/routes/geschichten/new/+page.svelte
@@ -1,42 +1,12 @@
@@ -46,18 +16,16 @@ async function handleSubmit(payload: {
{m.geschichten_new_button()}
- {#if errorMessage}
-
- {errorMessage}
+ {#if data.selectedType === 'STORY'}
+
+ {:else if data.selectedType === 'JOURNEY'}
+
+ {:else}
+
goto(`/geschichten/new?type=${type}`)} />
{/if}
-
-
diff --git a/frontend/src/routes/geschichten/new/StoryCreate.svelte b/frontend/src/routes/geschichten/new/StoryCreate.svelte
new file mode 100644
index 00000000..5bd49fdc
--- /dev/null
+++ b/frontend/src/routes/geschichten/new/StoryCreate.svelte
@@ -0,0 +1,50 @@
+
+
+{#if errorMessage}
+
+ {errorMessage}
+
+{/if}
+
+
diff --git a/frontend/src/routes/geschichten/new/TypeSelector.svelte b/frontend/src/routes/geschichten/new/TypeSelector.svelte
new file mode 100644
index 00000000..a6cc6848
--- /dev/null
+++ b/frontend/src/routes/geschichten/new/TypeSelector.svelte
@@ -0,0 +1,96 @@
+
+
+
+
+ {m.journey_selector_question()}
+
+
+
{
+ if (TYPES.includes(v as GeschichteType)) select(v as GeschichteType);
+ }}
+ >
+ {#each TYPES as type (type)}
+
+ {/each}
+
+
+
{announcement}
+
+ {#if !selected}
+
+ {m.journey_selector_aria_live_hint()}
+
+ {/if}
+
+
+
diff --git a/frontend/src/routes/geschichten/new/TypeSelector.svelte.spec.ts b/frontend/src/routes/geschichten/new/TypeSelector.svelte.spec.ts
new file mode 100644
index 00000000..8ddaa362
--- /dev/null
+++ b/frontend/src/routes/geschichten/new/TypeSelector.svelte.spec.ts
@@ -0,0 +1,99 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page, userEvent } from 'vitest/browser';
+
+vi.mock('$app/navigation', () => ({
+ goto: vi.fn()
+}));
+
+const { default: TypeSelector } = await import('./TypeSelector.svelte');
+
+afterEach(cleanup);
+
+describe('TypeSelector', () => {
+ it('renders both type cards', async () => {
+ render(TypeSelector, { props: { onweiter: vi.fn() } });
+
+ await expect.element(page.getByRole('radio', { name: /Geschichte/i })).toBeVisible();
+ await expect.element(page.getByRole('radio', { name: /Lesereise/i })).toBeVisible();
+ });
+
+ it('radiogroup is correctly labelled', async () => {
+ render(TypeSelector, { props: { onweiter: vi.fn() } });
+
+ const group = document.querySelector('[role="radiogroup"]');
+ const labelledBy = group?.getAttribute('aria-labelledby');
+ const labelEl = labelledBy ? document.getElementById(labelledBy) : null;
+ expect(labelEl?.textContent?.trim().length).toBeGreaterThan(0);
+ });
+
+ it('Weiter button has aria-disabled=true when nothing is selected', async () => {
+ render(TypeSelector, { props: { onweiter: vi.fn() } });
+
+ const weiter = document.querySelector('button[type="button"]:not([role="radio"])');
+ expect(weiter?.getAttribute('aria-disabled')).toBe('true');
+ });
+
+ it('no card is aria-checked when nothing is selected', async () => {
+ render(TypeSelector, { props: { onweiter: vi.fn() } });
+
+ const radios = Array.from(document.querySelectorAll('[role="radio"]'));
+ const anyChecked = radios.some((r) => r.getAttribute('aria-checked') === 'true');
+ expect(anyChecked).toBe(false);
+ });
+
+ it('with no selection: first card has tabindex=0, second has tabindex=-1', async () => {
+ render(TypeSelector, { props: { onweiter: vi.fn() } });
+
+ const radios = Array.from(document.querySelectorAll('[role="radio"]'));
+ expect(radios[0]?.getAttribute('tabindex')).toBe('0');
+ expect(radios[1]?.getAttribute('tabindex')).toBe('-1');
+ });
+
+ it('clicking STORY card sets aria-checked=true and enables Weiter', async () => {
+ render(TypeSelector, { props: { onweiter: vi.fn() } });
+
+ const storyCard = page.getByRole('radio', { name: /Geschichte/i });
+ await userEvent.click(storyCard);
+
+ await expect.element(storyCard).toHaveAttribute('aria-checked', 'true');
+ const weiter = document.querySelector('button[type="button"]:not([role="radio"])');
+ expect(weiter?.getAttribute('aria-disabled')).toBe('false');
+ });
+
+ it('clicking JOURNEY card sets aria-checked=true', async () => {
+ render(TypeSelector, { props: { onweiter: vi.fn() } });
+
+ const journeyCard = page.getByRole('radio', { name: /Lesereise/i });
+ await userEvent.click(journeyCard);
+
+ await expect.element(journeyCard).toHaveAttribute('aria-checked', 'true');
+ });
+
+ it('clicking Weiter after selection calls onweiter with the selected type', async () => {
+ const onweiter = vi.fn();
+ render(TypeSelector, { props: { onweiter } });
+
+ await userEvent.click(page.getByRole('radio', { name: /Geschichte/i }));
+ const weiter = page.getByRole('button', { name: /Weiter/i });
+ await userEvent.click(weiter);
+
+ expect(onweiter).toHaveBeenCalledWith('STORY');
+ });
+
+ it('clicking Weiter without selection does NOT call onweiter', async () => {
+ const onweiter = vi.fn();
+ render(TypeSelector, { props: { onweiter } });
+
+ const weiter = page.getByRole('button', { name: /Weiter/i });
+ await userEvent.click(weiter);
+
+ expect(onweiter).not.toHaveBeenCalled();
+ });
+
+ it('instructional text is visible when no type is selected', async () => {
+ render(TypeSelector, { props: { onweiter: vi.fn() } });
+
+ await expect.element(page.getByText(/Bitte wähle einen Typ/i)).toBeVisible();
+ });
+});