@bygghammar/cms-platform 0.63.0 → 0.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.63.0",
3
+ "version": "0.65.0",
4
4
  "description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -14,6 +14,8 @@ export type DashboardCard = {
14
14
  title: string;
15
15
  body: string;
16
16
  icon: ComponentType<{ className?: string }>;
17
+ /** Module switch that has to be on for the card to be shown. */
18
+ feature?: string;
17
19
  };
18
20
 
19
21
  /** Translation sweep modes offered on the dashboard. */
@@ -232,6 +232,8 @@ export type BackofficeHost = {
232
232
  title: string;
233
233
  body: string;
234
234
  icon: (props: { className?: string }) => ReactNode;
235
+ /** Module switch that has to be on for the card to be shown. */
236
+ feature?: string;
235
237
  }[];
236
238
  translateTarget?: string;
237
239
  translate?: HostServerFn;
@@ -45,6 +45,7 @@ import { SiteSettingsView } from "../SiteSettingsView";
45
45
  import { TrackingView } from "../TrackingView";
46
46
  import { UsersView } from "../UsersView";
47
47
  import { VersionView } from "../VersionView";
48
+ import { useFeatureGate } from "../shell/use-resolved-config";
48
49
  import { backofficeHost, backofficePaths } from "./host";
49
50
 
50
51
  export * from "./host";
@@ -189,7 +190,7 @@ export function RedirectsRoute() {
189
190
  }
190
191
 
191
192
  /** Code & tracking injection screen. */
192
- export function TrackingRoute() {
193
+ function TrackingRouteInner() {
193
194
  const { fns, locales } = backofficeHost();
194
195
  const list = useServerFn(fns.listInjections);
195
196
  const save = useServerFn(fns.saveInjection);
@@ -238,7 +239,7 @@ export function ContactDetailsRoute() {
238
239
  }
239
240
 
240
241
  /** Form defaults and retention screen. */
241
- export function FormSettingsRoute() {
242
+ function FormSettingsRouteInner() {
242
243
  const host = backofficeHost();
243
244
  const { fns } = host;
244
245
  const load = useServerFn(fns.getFormDefaults);
@@ -493,7 +494,7 @@ export function SitemapRoute() {
493
494
  }
494
495
 
495
496
  /** Email sending settings plus provider credentials. */
496
- export function EmailSettingsRoute() {
497
+ function EmailSettingsRouteInner() {
497
498
  const host = backofficeHost();
498
499
  const { fns } = host;
499
500
  const fetchSettings = useServerFn(fns.getEmailSettings);
@@ -559,7 +560,7 @@ export function NavigationRoute() {
559
560
  }
560
561
 
561
562
  /** Per-locale domain mapping. */
562
- export function DomainsRoute() {
563
+ function DomainsRouteInner() {
563
564
  const host = backofficeHost();
564
565
  const paths = backofficePaths(host);
565
566
  const qc = useQueryClient();
@@ -585,7 +586,7 @@ export function DomainsRoute() {
585
586
  }
586
587
 
587
588
  /** Testimonials / reviews editor backed by the shared content section. */
588
- export function ReviewsRoute() {
589
+ function ReviewsRouteInner() {
589
590
  const host = backofficeHost();
590
591
  const paths = backofficePaths(host);
591
592
  const qc = useQueryClient();
@@ -621,7 +622,7 @@ export function ReviewsRoute() {
621
622
  }
622
623
 
623
624
  /** Backup and restore, superadmin only. */
624
- export function BackupRoute() {
625
+ function BackupRouteInner() {
625
626
  const host = backofficeHost();
626
627
  const backup = host.backup;
627
628
  if (!backup) {
@@ -649,3 +650,45 @@ export function BackupRoute() {
649
650
  />
650
651
  );
651
652
  }
653
+
654
+ /** ReviewsRoute guarded by the "reviews" module switch. */
655
+ export function ReviewsRoute() {
656
+ const gate = useFeatureGate("reviews", "Reviews");
657
+ if (gate) return <>{gate}</>;
658
+ return <ReviewsRouteInner />;
659
+ }
660
+
661
+ /** DomainsRoute guarded by the "domains" module switch. */
662
+ export function DomainsRoute() {
663
+ const gate = useFeatureGate("domains", "Domains");
664
+ if (gate) return <>{gate}</>;
665
+ return <DomainsRouteInner />;
666
+ }
667
+
668
+ /** TrackingRoute guarded by the "tracking" module switch. */
669
+ export function TrackingRoute() {
670
+ const gate = useFeatureGate("tracking", "Code & tracking");
671
+ if (gate) return <>{gate}</>;
672
+ return <TrackingRouteInner />;
673
+ }
674
+
675
+ /** EmailSettingsRoute guarded by the "email" module switch. */
676
+ export function EmailSettingsRoute() {
677
+ const gate = useFeatureGate("email", "Email");
678
+ if (gate) return <>{gate}</>;
679
+ return <EmailSettingsRouteInner />;
680
+ }
681
+
682
+ /** BackupRoute guarded by the "backup" module switch. */
683
+ export function BackupRoute() {
684
+ const gate = useFeatureGate("backup", "Backup");
685
+ if (gate) return <>{gate}</>;
686
+ return <BackupRouteInner />;
687
+ }
688
+
689
+ /** FormSettingsRoute guarded by the "forms" module switch. */
690
+ export function FormSettingsRoute() {
691
+ const gate = useFeatureGate("forms", "Forms");
692
+ if (gate) return <>{gate}</>;
693
+ return <FormSettingsRouteInner />;
694
+ }
@@ -8,6 +8,7 @@
8
8
 
9
9
  /* eslint-disable @typescript-eslint/no-explicit-any */
10
10
 
11
+ import { useFeatureGate } from "../shell/use-resolved-config";
11
12
  import { Link, useNavigate, useParams, useSearch } from "@tanstack/react-router";
12
13
  import { useQuery, useQueryClient } from "@tanstack/react-query";
13
14
  import { useServerFn } from "@tanstack/react-start";
@@ -113,7 +114,7 @@ export function MediaRoute() {
113
114
  }
114
115
 
115
116
  /** Forms index: list, create, duplicate, delete and the trash bin. */
116
- export function FormsIndexRoute() {
117
+ function FormsIndexRouteInner() {
117
118
  const forms = requireHostGroup("forms");
118
119
  const { fns } = backofficeHost();
119
120
  const qc = useQueryClient();
@@ -158,7 +159,7 @@ export function FormsIndexRoute() {
158
159
  }
159
160
 
160
161
  /** Form editor for a single form key. */
161
- export function FormEditorRoute() {
162
+ function FormEditorRouteInner() {
162
163
  const forms = requireHostGroup("forms");
163
164
  const { fns } = backofficeHost();
164
165
  const params = useParams({ strict: false }) as { key?: string };
@@ -231,7 +232,7 @@ export function validateSubmissionSearch(search: Record<string, unknown>): Submi
231
232
  }
232
233
 
233
234
  /** Submission inbox with filters, paging, exports and delivery retries. */
234
- export function FormSubmissionsRoute() {
235
+ function FormSubmissionsRouteInner() {
235
236
  const forms = requireHostGroup("forms");
236
237
  const host = backofficeHost();
237
238
  const paths = backofficePaths(host);
@@ -302,7 +303,7 @@ export function FormSubmissionsRoute() {
302
303
  }
303
304
 
304
305
  /** Delivery attempt dashboard (retry / resolve failed notifications). */
305
- export function FormDeliveriesRoute() {
306
+ function FormDeliveriesRouteInner() {
306
307
  const forms = requireHostGroup("forms");
307
308
  const host = backofficeHost();
308
309
  const paths = backofficePaths(host);
@@ -358,3 +359,31 @@ export function FormDeliveriesRoute() {
358
359
  />
359
360
  );
360
361
  }
362
+
363
+ /** FormsIndexRoute guarded by the "forms" module switch. */
364
+ export function FormsIndexRoute() {
365
+ const gate = useFeatureGate("forms", "Forms");
366
+ if (gate) return <>{gate}</>;
367
+ return <FormsIndexRouteInner />;
368
+ }
369
+
370
+ /** FormEditorRoute guarded by the "forms" module switch. */
371
+ export function FormEditorRoute() {
372
+ const gate = useFeatureGate("forms", "Forms");
373
+ if (gate) return <>{gate}</>;
374
+ return <FormEditorRouteInner />;
375
+ }
376
+
377
+ /** FormSubmissionsRoute guarded by the "forms" module switch. */
378
+ export function FormSubmissionsRoute() {
379
+ const gate = useFeatureGate("forms", "Forms");
380
+ if (gate) return <>{gate}</>;
381
+ return <FormSubmissionsRouteInner />;
382
+ }
383
+
384
+ /** FormDeliveriesRoute guarded by the "forms" module switch. */
385
+ export function FormDeliveriesRoute() {
386
+ const gate = useFeatureGate("forms", "Forms");
387
+ if (gate) return <>{gate}</>;
388
+ return <FormDeliveriesRouteInner />;
389
+ }
@@ -17,6 +17,7 @@ import { toast } from "sonner";
17
17
  import { PageBuilderView } from "../PageBuilderView";
18
18
  import { SectionEditorView } from "../SectionEditorView";
19
19
  import { DashboardView, type DashboardCard } from "../DashboardView";
20
+ import { useResolvedSiteConfig } from "../shell/use-resolved-config";
20
21
  import { ResetPasswordView } from "../ResetPasswordView";
21
22
  import { backofficeHost, backofficePaths, requireHostGroup } from "./host";
22
23
 
@@ -204,13 +205,18 @@ export function SectionEditorRoute() {
204
205
  /** Backoffice dashboard for `/<backoffice>/`. */
205
206
  export function DashboardRoute() {
206
207
  const dashboard = requireHostGroup("dashboard");
208
+ const { config } = useResolvedSiteConfig();
207
209
  const translateFn = dashboard.translate;
208
210
  const runTranslate = useServerFn(translateFn ?? (async () => ({})));
209
211
 
210
212
  return (
211
213
  <DashboardView
212
214
  deps={{
213
- cards: dashboard.cards as unknown as readonly DashboardCard[],
215
+ cards: (dashboard.cards as unknown as readonly DashboardCard[]).filter(
216
+ (c) =>
217
+ !c.feature ||
218
+ (config.features as Record<string, boolean | undefined>)[c.feature] === true,
219
+ ),
214
220
  renderLink: ({ to, className, children }) => (
215
221
  <Link to={to as any} className={className}>
216
222
  {children}
@@ -7,3 +7,11 @@ export type {
7
7
  export { backofficeNav } from "./nav-model";
8
8
  export type { BackofficeNavItem, BackofficeNavKey, BackofficeNavOptions } from "./nav-model";
9
9
  export { backofficeRedirectHost, isDevelopmentHost } from "./domain-guard";
10
+ export {
11
+ FeatureDisabledNotice,
12
+ SITE_SETTINGS_QUERY_KEY,
13
+ useBackofficeNav,
14
+ useFeatureEnabled,
15
+ useFeatureGate,
16
+ useResolvedSiteConfig,
17
+ } from "./use-resolved-config";
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Live backoffice site configuration.
3
+ *
4
+ * The sidebar, the dashboard cards and the module screens must all obey the
5
+ * module switches saved under Site settings, not just the values compiled into
6
+ * `site.config.ts`. These hooks resolve the code config layered with the stored
7
+ * overrides through the host's `getSiteSettings` function, sharing the
8
+ * `["admin","site-settings"]` query key with the settings screen so a save
9
+ * updates the menu immediately.
10
+ */
11
+ import type { ReactNode } from "react";
12
+ import { useQuery } from "@tanstack/react-query";
13
+ import { useServerFn } from "@tanstack/react-start";
14
+
15
+ import type { SiteConfig, SiteFeatureFlags } from "../../config/site-config";
16
+ import { resolveSiteConfig } from "../../config/site-config";
17
+ import { backofficeHost } from "../routes/host";
18
+ import { backofficeNav, type BackofficeNavItem, type BackofficeNavOptions } from "./nav-model";
19
+
20
+ /** Query key shared with the site-settings screen. */
21
+ export const SITE_SETTINGS_QUERY_KEY = ["admin", "site-settings"] as const;
22
+
23
+ /**
24
+ * Code config merged with the stored overrides. While loading, the code config
25
+ * is returned so the shell renders without a flash of empty navigation.
26
+ */
27
+ export function useResolvedSiteConfig(): { config: SiteConfig; isLoading: boolean } {
28
+ const host = backofficeHost();
29
+ const load = useServerFn(host.fns.getSiteSettings);
30
+ const q = useQuery({
31
+ queryKey: SITE_SETTINGS_QUERY_KEY,
32
+ queryFn: () => load() as Promise<{ config?: SiteConfig; overrides?: unknown }>,
33
+ staleTime: 30_000,
34
+ });
35
+
36
+ const resolved =
37
+ (q.data?.config as SiteConfig | undefined) ??
38
+ (q.data?.overrides
39
+ ? resolveSiteConfig(host.siteConfig, q.data.overrides as never)
40
+ : host.siteConfig);
41
+
42
+ return { config: resolved, isLoading: q.isLoading };
43
+ }
44
+
45
+ /** Sidebar model built from the live (override-aware) configuration. */
46
+ export function useBackofficeNav(options: BackofficeNavOptions = {}): BackofficeNavItem[] {
47
+ const { config } = useResolvedSiteConfig();
48
+ return backofficeNav(config, options);
49
+ }
50
+
51
+ /**
52
+ * Whether a module is enabled right now. `undefined` while settings load, so a
53
+ * caller can avoid flashing a "turned off" notice before the answer is known.
54
+ */
55
+ export function useFeatureEnabled(flag: keyof SiteFeatureFlags): boolean | undefined {
56
+ const { config, isLoading } = useResolvedSiteConfig();
57
+ if (isLoading) return undefined;
58
+ return config.features[flag] === true;
59
+ }
60
+
61
+ /** Notice shown instead of a screen whose module is switched off. */
62
+ export function FeatureDisabledNotice({ label }: { label?: string }) {
63
+ return (
64
+ <div className="p-8 md:p-12">
65
+ <h1 className="font-serif text-2xl text-ink">
66
+ {label ? `${label} is turned off` : "This module is turned off"}
67
+ </h1>
68
+ <p className="mt-3 max-w-md text-sm text-ink/65">
69
+ Turn it back on under Site settings → Modules to use this screen.
70
+ </p>
71
+ </div>
72
+ );
73
+ }
74
+
75
+ /**
76
+ * Guard for a module screen: returns a node to render instead of the screen
77
+ * when the module is disabled, and `null` when the screen may render.
78
+ */
79
+ export function useFeatureGate(flag: keyof SiteFeatureFlags, label?: string): ReactNode | null {
80
+ const enabled = useFeatureEnabled(flag);
81
+ if (enabled === undefined) return <div className="p-8 text-sm text-ink/50">Loading…</div>;
82
+ if (enabled) return null;
83
+ return <FeatureDisabledNotice label={label} />;
84
+ }
@@ -1,4 +1,4 @@
1
- import { useState } from "react";
1
+ import { useCallback, useEffect, useRef, useState, type TouchEvent as ReactTouchEvent } from "react";
2
2
  import { Check, ChevronLeft, ChevronRight } from "lucide-react";
3
3
  import { useT } from "../../i18n";
4
4
  import { Reveal } from "../../ui/Reveal";
@@ -11,26 +11,99 @@ const TONE: Record<string, string> = {
11
11
  ice: "bg-ice",
12
12
  };
13
13
 
14
+ /** Gradient start colour for the mobile rail edge fades, per section tone. */
15
+ const FADE: Record<string, string> = {
16
+ white: "from-white",
17
+ paper: "from-paper",
18
+ ice: "from-ice",
19
+ };
20
+
21
+ /** Minimum horizontal travel (px) before a touch counts as a category swipe. */
22
+ const SWIPE_THRESHOLD = 48;
23
+
14
24
  /**
15
25
  * Cabin categories as clickable tabs — each tab keeps its own description,
16
26
  * bullet list and image gallery. Everything is editable in the page builder.
27
+ *
28
+ * On mobile the tabs become a snap-scrolling "category rail": the active tab is
29
+ * auto-centred, edge fades signal that more categories exist, and a prev/next
30
+ * stepper with a counter lets people move without swiping at all.
17
31
  */
18
32
  export function CabinTabsBlock({ props }: { props: CabinTabsProps }) {
19
33
  const { t } = useT();
20
34
  const items = props.items ?? [];
21
35
  const [active, setActive] = useState(0);
22
36
  const [slide, setSlide] = useState(0);
37
+ const [edges, setEdges] = useState({ start: false, end: false });
23
38
 
24
- if (items.length === 0) return null;
39
+ const railRef = useRef<HTMLDivElement | null>(null);
40
+ const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
41
+ const touchStart = useRef<{ x: number; y: number } | null>(null);
25
42
 
26
- const item = items[Math.min(active, items.length - 1)];
27
- const images = (item.images ?? []).filter((i) => i.src);
28
- const image = images.length > 0 ? images[slide % images.length] : undefined;
29
- const tone = TONE[props.tone ?? "white"] ?? "bg-white";
43
+ const count = items.length;
44
+ const index = count > 0 ? Math.min(active, count - 1) : 0;
45
+
46
+ const syncEdges = useCallback(() => {
47
+ const el = railRef.current;
48
+ if (!el) return;
49
+ const max = el.scrollWidth - el.clientWidth;
50
+ setEdges({ start: el.scrollLeft > 4, end: max > 4 && el.scrollLeft < max - 4 });
51
+ }, []);
52
+
53
+ useEffect(() => {
54
+ syncEdges();
55
+ const el = railRef.current;
56
+ if (!el) return;
57
+ el.addEventListener("scroll", syncEdges, { passive: true });
58
+ window.addEventListener("resize", syncEdges);
59
+ return () => {
60
+ el.removeEventListener("scroll", syncEdges);
61
+ window.removeEventListener("resize", syncEdges);
62
+ };
63
+ }, [syncEdges, count]);
30
64
 
31
- const select = (i: number) => {
65
+ // Keep the active tab fully visible in the middle of the rail.
66
+ useEffect(() => {
67
+ const tab = tabRefs.current[index];
68
+ if (!tab) return;
69
+ tab.scrollIntoView({ inline: "center", block: "nearest", behavior: "smooth" });
70
+ }, [index]);
71
+
72
+ const select = useCallback((i: number) => {
32
73
  setActive(i);
33
74
  setSlide(0);
75
+ }, []);
76
+
77
+ const step = useCallback(
78
+ (delta: number) => {
79
+ if (count === 0) return;
80
+ select((index + delta + count) % count);
81
+ },
82
+ [count, index, select],
83
+ );
84
+
85
+ if (count === 0) return null;
86
+
87
+ const item = items[index];
88
+ const images = (item.images ?? []).filter((i) => i.src);
89
+ const image = images.length > 0 ? images[slide % images.length] : undefined;
90
+ const toneKey = props.tone ?? "white";
91
+ const tone = TONE[toneKey] ?? TONE.white;
92
+ const fade = FADE[toneKey] ?? FADE.white;
93
+
94
+ const onTouchStart = (e: ReactTouchEvent) => {
95
+ const touch = e.touches[0];
96
+ touchStart.current = { x: touch.clientX, y: touch.clientY };
97
+ };
98
+ const onTouchEnd = (e: ReactTouchEvent) => {
99
+ const start = touchStart.current;
100
+ touchStart.current = null;
101
+ if (!start || count < 2) return;
102
+ const touch = e.changedTouches[0];
103
+ const dx = touch.clientX - start.x;
104
+ const dy = touch.clientY - start.y;
105
+ if (Math.abs(dx) < SWIPE_THRESHOLD || Math.abs(dx) < Math.abs(dy) * 1.5) return;
106
+ step(dx < 0 ? 1 : -1);
34
107
  };
35
108
 
36
109
  return (
@@ -60,34 +133,93 @@ export function CabinTabsBlock({ props }: { props: CabinTabsProps }) {
60
133
 
61
134
  <Reveal>
62
135
  <div className="mt-10 grid gap-8 lg:grid-cols-[minmax(0,15rem)_minmax(0,1fr)_minmax(0,1.05fr)] lg:gap-10">
63
- {/* Tabs */}
64
- <div
65
- role="tablist"
66
- aria-label={t(props.title) || "Categories"}
67
- className="flex gap-2 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible lg:pb-0"
68
- >
69
- {items.map((it, i) => {
70
- const on = i === Math.min(active, items.length - 1);
71
- return (
136
+ {/* Category rail (mobile) / vertical tab list (desktop) */}
137
+ <div className="min-w-0">
138
+ <div className="relative lg:static">
139
+ <div
140
+ ref={railRef}
141
+ role="tablist"
142
+ aria-label={t(props.title) || "Categories"}
143
+ onKeyDown={(e) => {
144
+ if (e.key === "ArrowRight" || e.key === "ArrowDown") {
145
+ e.preventDefault();
146
+ step(1);
147
+ } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
148
+ e.preventDefault();
149
+ step(-1);
150
+ }
151
+ }}
152
+ className="-mx-6 flex snap-x snap-mandatory gap-2 overflow-x-auto scroll-smooth px-6 pb-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden lg:mx-0 lg:flex-col lg:overflow-visible lg:px-0 lg:pb-0"
153
+ >
154
+ {items.map((it, i) => {
155
+ const on = i === index;
156
+ return (
157
+ <button
158
+ key={it.id ?? i}
159
+ ref={(el) => {
160
+ tabRefs.current[i] = el;
161
+ }}
162
+ role="tab"
163
+ aria-selected={on}
164
+ tabIndex={on ? 0 : -1}
165
+ onClick={() => select(i)}
166
+ className={`shrink-0 snap-center border px-4 py-3 text-left text-[0.7rem] uppercase tracking-[0.2em] transition-colors lg:shrink ${
167
+ on
168
+ ? "border-sea bg-sea text-white"
169
+ : "border-ink/12 bg-white text-ink/70 hover:border-sea hover:text-ink"
170
+ }`}
171
+ >
172
+ {t(it.label)}
173
+ </button>
174
+ );
175
+ })}
176
+ </div>
177
+
178
+ {/* Edge fades: a tab disappearing under a gradient reads as "more". */}
179
+ <div
180
+ aria-hidden
181
+ className={`pointer-events-none absolute inset-y-0 -left-6 w-10 bg-gradient-to-r ${fade} to-transparent transition-opacity duration-200 lg:hidden ${
182
+ edges.start ? "opacity-100" : "opacity-0"
183
+ }`}
184
+ />
185
+ <div
186
+ aria-hidden
187
+ className={`pointer-events-none absolute inset-y-0 -right-6 w-12 bg-gradient-to-l ${fade} to-transparent transition-opacity duration-200 lg:hidden ${
188
+ edges.end ? "opacity-100" : "opacity-0"
189
+ }`}
190
+ />
191
+ </div>
192
+
193
+ {count > 1 ? (
194
+ <div className="mt-3 flex items-center justify-center gap-3 lg:hidden">
72
195
  <button
73
- key={it.id ?? i}
74
- role="tab"
75
- aria-selected={on}
76
- onClick={() => select(i)}
77
- className={`shrink-0 border px-4 py-3 text-left text-[0.7rem] uppercase tracking-[0.2em] transition-colors lg:shrink ${
78
- on
79
- ? "border-sea bg-sea text-white"
80
- : "border-ink/12 bg-white text-ink/70 hover:border-sea hover:text-ink"
81
- }`}
196
+ type="button"
197
+ onClick={() => step(-1)}
198
+ aria-label="Föregående kategori"
199
+ className="border border-ink/12 bg-white p-2 text-ink/70 transition-colors hover:border-sea hover:text-ink"
82
200
  >
83
- {t(it.label)}
201
+ <ChevronLeft className="size-4" />
84
202
  </button>
85
- );
86
- })}
203
+ <span
204
+ aria-live="polite"
205
+ className="min-w-0 truncate text-[0.7rem] uppercase tracking-[0.2em] text-ink/60"
206
+ >
207
+ {t(item.label)} · {index + 1} / {count}
208
+ </span>
209
+ <button
210
+ type="button"
211
+ onClick={() => step(1)}
212
+ aria-label="Nästa kategori"
213
+ className="border border-ink/12 bg-white p-2 text-ink/70 transition-colors hover:border-sea hover:text-ink"
214
+ >
215
+ <ChevronRight className="size-4" />
216
+ </button>
217
+ </div>
218
+ ) : null}
87
219
  </div>
88
220
 
89
221
  {/* Copy */}
90
- <div className="min-w-0">
222
+ <div className="min-w-0" onTouchStart={onTouchStart} onTouchEnd={onTouchEnd}>
91
223
  <h3 className="font-serif text-2xl md:text-3xl text-sea">
92
224
  {t(item.title) || t(item.label)}
93
225
  </h3>