@eifi1/ui-kit 0.42.0 → 0.44.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/README.md +13 -7
- package/dist/admin/admin-area-labels.d.ts +4 -0
- package/dist/admin/admin-area-labels.js +3 -1
- package/dist/admin/admin-area-labels.js.map +1 -1
- package/dist/admin/admin-overview.d.ts +25 -8
- package/dist/admin/admin-overview.js +26 -3
- package/dist/admin/admin-overview.js.map +1 -1
- package/dist/admin/admin-sections.d.ts +36 -5
- package/dist/admin/admin-sections.js +35 -18
- package/dist/admin/admin-sections.js.map +1 -1
- package/dist/billing/billing-banner.d.ts +8 -0
- package/dist/billing/billing-banner.js +25 -2
- package/dist/billing/billing-banner.js.map +1 -1
- package/dist/billing/billing-labels.d.ts +4 -0
- package/dist/billing/billing-labels.js +1 -0
- package/dist/billing/billing-labels.js.map +1 -1
- package/dist/billing/cancel-request.d.ts +53 -0
- package/dist/billing/cancel-request.js +21 -0
- package/dist/billing/cancel-request.js.map +1 -0
- package/dist/billing/provider-page.d.ts +49 -0
- package/dist/billing/provider-page.js +33 -0
- package/dist/billing/provider-page.js.map +1 -0
- package/dist/billing/subscription-actions.d.ts +32 -1
- package/dist/billing/subscription-actions.js +57 -4
- package/dist/billing/subscription-actions.js.map +1 -1
- package/dist/components/account-chips.d.ts +8 -1
- package/dist/components/account-chips.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/feedback/feedback-menu.d.ts +7 -4
- package/dist/feedback/feedback-menu.js +4 -2
- package/dist/feedback/feedback-menu.js.map +1 -1
- package/dist/hooks/use-search-param-state.js +4 -1
- package/dist/hooks/use-search-param-state.js.map +1 -1
- package/dist/i18n/defaults.js +2 -0
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.js +36 -2
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +3 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +51 -2
- package/dist/i18n/locales/en.js.map +3 -3
- package/dist/i18n/locales/es.js +36 -2
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +36 -2
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +36 -2
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +36 -2
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +36 -2
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.js +15 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/search/global-search.d.ts +3 -0
- package/dist/search/global-search.js +3 -1
- package/dist/search/global-search.js.map +1 -1
- package/dist/settings/settings-catalogue.d.ts +9 -1
- package/dist/settings/settings-catalogue.js +1 -1
- package/dist/settings/settings-catalogue.js.map +1 -1
- package/dist/shell/admin-menu.d.ts +26 -3
- package/dist/shell/admin-menu.js +8 -4
- package/dist/shell/admin-menu.js.map +1 -1
- package/dist/shell/dev-switchers.js +5 -0
- package/dist/shell/dev-switchers.js.map +1 -1
- package/dist/shell/top-bar-parts-labels.d.ts +6 -0
- package/dist/shell/top-bar-parts-labels.js +4 -1
- package/dist/shell/top-bar-parts-labels.js.map +1 -1
- package/dist/shell/tour-menu.d.ts +19 -10
- package/dist/shell/tour-menu.js +20 -6
- package/dist/shell/tour-menu.js.map +1 -1
- package/dist/shell.d.ts +2 -0
- package/dist/shell.js +2 -0
- package/dist/shell.js.map +1 -1
- package/dist/tour/tour-catalogue-labels.d.ts +44 -0
- package/dist/tour/tour-catalogue-labels.js +32 -0
- package/dist/tour/tour-catalogue-labels.js.map +1 -0
- package/dist/tour/tour-catalogue.d.ts +68 -0
- package/dist/tour/tour-catalogue.js +189 -0
- package/dist/tour/tour-catalogue.js.map +1 -0
- package/dist/tour/tour-info.d.ts +112 -0
- package/dist/tour/tour-info.js +36 -0
- package/dist/tour/tour-info.js.map +1 -0
- package/dist/tour/tour.d.ts +38 -2
- package/dist/tour/tour.js +40 -3
- package/dist/tour/tour.js.map +1 -1
- package/dist/tour/use-tour-progress.d.ts +116 -0
- package/dist/tour/use-tour-progress.js +146 -0
- package/dist/tour/use-tour-progress.js.map +1 -0
- package/dist/tour/use-tour-url-sync.d.ts +58 -0
- package/dist/tour/use-tour-url-sync.js +110 -0
- package/dist/tour/use-tour-url-sync.js.map +1 -0
- package/dist/tour.d.ts +8 -0
- package/dist/tour.js +19 -0
- package/dist/tour.js.map +1 -1
- package/package.json +1 -1
- package/src/admin/admin-area-labels.ts +7 -1
- package/src/admin/admin-overview.tsx +61 -10
- package/src/admin/admin-sections.tsx +109 -48
- package/src/billing/billing-banner.tsx +25 -2
- package/src/billing/billing-labels.ts +6 -0
- package/src/billing/cancel-request.ts +75 -0
- package/src/billing/provider-page.ts +84 -0
- package/src/billing/subscription-actions.tsx +97 -11
- package/src/components/account-chips.tsx +8 -1
- package/src/feedback/feedback-menu.tsx +8 -3
- package/src/hooks/use-search-param-state.ts +6 -1
- package/src/i18n/defaults.ts +2 -0
- package/src/i18n/german.ts +36 -1
- package/src/i18n/kit-labels.tsx +3 -0
- package/src/i18n/locales/en.ts +13 -0
- package/src/i18n/locales/es.ts +36 -1
- package/src/i18n/locales/fr.ts +36 -1
- package/src/i18n/locales/hu.ts +36 -1
- package/src/i18n/locales/it.ts +36 -1
- package/src/i18n/locales/zh.ts +36 -1
- package/src/i18n/review.ts +14 -0
- package/src/index.ts +8 -0
- package/src/search/global-search.tsx +5 -0
- package/src/settings/settings-catalogue.ts +10 -2
- package/src/shell/admin-menu.tsx +34 -5
- package/src/shell/dev-switchers.tsx +8 -0
- package/src/shell/top-bar-parts-labels.ts +9 -0
- package/src/shell/tour-menu.tsx +50 -15
- package/src/shell.ts +3 -0
- package/src/tour/tour-catalogue-labels.ts +75 -0
- package/src/tour/tour-catalogue.tsx +316 -0
- package/src/tour/tour-info.ts +144 -0
- package/src/tour/tour.tsx +84 -3
- package/src/tour/use-tour-progress.ts +299 -0
- package/src/tour/use-tour-url-sync.ts +221 -0
- package/src/tour.ts +12 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { useId, type ReactNode } from "react";
|
|
2
|
+
import { Circle, CircleCheck, Clock, Play } from "lucide-react";
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
import { useKitFormat } from "../lib/format";
|
|
5
|
+
import { foldForSearch } from "../lib/text-match";
|
|
6
|
+
import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
|
|
7
|
+
import { Button } from "../components/button";
|
|
8
|
+
import { Card } from "../components/card";
|
|
9
|
+
import { Chip } from "../components/chip";
|
|
10
|
+
import { Caption } from "../components/text";
|
|
11
|
+
import { EmptyState } from "../components/empty-state";
|
|
12
|
+
import { SearchField } from "../components/search-field";
|
|
13
|
+
import { Select } from "../components/select";
|
|
14
|
+
import { ToggleGroup } from "../components/toggle-group";
|
|
15
|
+
import { DEFAULT_TOUR_CATALOGUE_LABELS, type TourCatalogueLabels } from "./tour-catalogue-labels";
|
|
16
|
+
import { isTourDone, isTourNew, isTourReset, type TourCompletions, type TourGroup, type TourInfo } from "./tour-info";
|
|
17
|
+
import type { TourCatalogueFilter, TourStateFilter } from "./use-tour-url-sync";
|
|
18
|
+
|
|
19
|
+
export { DEFAULT_TOUR_CATALOGUE_LABELS };
|
|
20
|
+
export type { TourCatalogueLabels };
|
|
21
|
+
|
|
22
|
+
/* ── The card ───────────────────────────────────────────────────────────────── */
|
|
23
|
+
|
|
24
|
+
export interface TourCatalogueCardProps {
|
|
25
|
+
tour: TourInfo;
|
|
26
|
+
/** The completion's ISO timestamp, `null` for one without a date; absent while untaken. */
|
|
27
|
+
completedAt?: string | null;
|
|
28
|
+
/** NEW — `isTourNew`. The catalogue works it out; a card on its own is told. */
|
|
29
|
+
isNew?: boolean;
|
|
30
|
+
onLaunch: (id: string) => void;
|
|
31
|
+
/** "Mark as not done". Without it, the link is not drawn. */
|
|
32
|
+
onReset?: (id: string) => void;
|
|
33
|
+
/** Formats the completion and update days. Default: the kit's medium date in the provider's locale. */
|
|
34
|
+
formatDate?: (iso: string) => string;
|
|
35
|
+
labels?: LabelOverride<TourCatalogueLabels>;
|
|
36
|
+
className?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One tour's card — keksdose's (tours-page.tsx `TourCard`, dev#557), laid out top to
|
|
41
|
+
* bottom so the cards sit side by side: the state icon beside a column holding the tour's
|
|
42
|
+
* icon, title, NEW (or "Updated", §8) and the app's `badge`, the one-liner and, from `sm`,
|
|
43
|
+
* the longer account. The foot is pinned to the bottom edge, so every Start in a row of
|
|
44
|
+
* cards lands on one line: the points chip, the step count and the duration, the
|
|
45
|
+
* completion (or reset) line, "Mark as not done" and Start / Replay. Points, steps and
|
|
46
|
+
* duration are each left out when the tour has none — no gap, no "0 points" (KS).
|
|
47
|
+
*/
|
|
48
|
+
export function TourCatalogueCard({
|
|
49
|
+
tour,
|
|
50
|
+
completedAt,
|
|
51
|
+
isNew = false,
|
|
52
|
+
onLaunch,
|
|
53
|
+
onReset,
|
|
54
|
+
formatDate: formatDateProp,
|
|
55
|
+
labels: labelsProp,
|
|
56
|
+
className,
|
|
57
|
+
}: TourCatalogueCardProps) {
|
|
58
|
+
const words = useKitLabels("tourCatalogue", DEFAULT_TOUR_CATALOGUE_LABELS, labelsProp);
|
|
59
|
+
const { formatDate } = useKitFormat();
|
|
60
|
+
const day = formatDateProp ?? ((iso: string) => formatDate(iso, "medium"));
|
|
61
|
+
const titleId = useId();
|
|
62
|
+
const done = isTourDone(tour, completedAt);
|
|
63
|
+
const reset = isTourReset(tour, completedAt);
|
|
64
|
+
const hasFacts = tour.points != null || tour.steps != null || tour.duration != null;
|
|
65
|
+
const note = done
|
|
66
|
+
? completedAt
|
|
67
|
+
? words.completedOn(day(completedAt))
|
|
68
|
+
: words.completed
|
|
69
|
+
: reset && tour.updated
|
|
70
|
+
? words.resetOn(day(tour.updated.date), tour.updated.reason)
|
|
71
|
+
: null;
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<Card className={cn("flex h-full flex-col p-4", className)}>
|
|
75
|
+
{/* Title, one-liner and the long body share ONE left edge, in the column beside the
|
|
76
|
+
state icon. */}
|
|
77
|
+
<div className="flex flex-1 items-start gap-3">
|
|
78
|
+
<div className={cn("mt-0.5 shrink-0", done ? "text-success" : "text-muted")}>
|
|
79
|
+
{done ? <CircleCheck aria-hidden className="size-5" /> : <Circle aria-hidden className="size-5" />}
|
|
80
|
+
<span className="sr-only">{done ? words.completed : words.notCompleted}</span>
|
|
81
|
+
</div>
|
|
82
|
+
<div className="min-w-0 flex-1">
|
|
83
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
84
|
+
{tour.icon != null && (
|
|
85
|
+
<span aria-hidden className="inline-flex shrink-0 text-brand [&_svg]:size-4">
|
|
86
|
+
{tour.icon}
|
|
87
|
+
</span>
|
|
88
|
+
)}
|
|
89
|
+
{/* The id on the words, not the heading: the Start button is described by them. */}
|
|
90
|
+
<h3 className="font-medium">
|
|
91
|
+
<span id={titleId}>{tour.title}</span>
|
|
92
|
+
</h3>
|
|
93
|
+
{/* It retires itself the moment the tour is taken — nothing to dismiss. */}
|
|
94
|
+
{isNew && !reset && (
|
|
95
|
+
<Chip size="sm" tone="brand" caps className="px-1.5">
|
|
96
|
+
{words.badgeNew}
|
|
97
|
+
</Chip>
|
|
98
|
+
)}
|
|
99
|
+
{reset && (
|
|
100
|
+
<Chip size="sm" tone="brand" caps className="px-1.5">
|
|
101
|
+
{words.badgeUpdated}
|
|
102
|
+
</Chip>
|
|
103
|
+
)}
|
|
104
|
+
{tour.badge}
|
|
105
|
+
</div>
|
|
106
|
+
{tour.description && <p className="mt-1 text-sm text-muted">{tour.description}</p>}
|
|
107
|
+
{/* `hidden sm:block`: one card per row is the phone, where these paragraphs
|
|
108
|
+
would turn the page into a scroll; the search still reads them. */}
|
|
109
|
+
{tour.about && <p className="mt-2 hidden text-sm leading-relaxed text-secondary sm:block">{tour.about}</p>}
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
<div className="mt-3 flex flex-wrap items-end justify-between gap-2">
|
|
113
|
+
<div className="min-w-0">
|
|
114
|
+
{hasFacts && (
|
|
115
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
116
|
+
{tour.points != null && (
|
|
117
|
+
<Chip size="sm" tone="brand">
|
|
118
|
+
{words.points(tour.points)}
|
|
119
|
+
</Chip>
|
|
120
|
+
)}
|
|
121
|
+
{tour.steps != null && <Caption as="span">{words.steps(tour.steps)}</Caption>}
|
|
122
|
+
{tour.duration != null && (
|
|
123
|
+
<Caption as="span" className="inline-flex items-center gap-1">
|
|
124
|
+
<Clock aria-hidden className="size-3" />
|
|
125
|
+
{words.duration(tour.duration)}
|
|
126
|
+
</Caption>
|
|
127
|
+
)}
|
|
128
|
+
</div>
|
|
129
|
+
)}
|
|
130
|
+
{note && <p className={cn("text-xs text-muted", hasFacts && "mt-1")}>{note}</p>}
|
|
131
|
+
</div>
|
|
132
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
133
|
+
{done && onReset && (
|
|
134
|
+
<Button variant="link" tone="muted" size="sm" aria-describedby={titleId} onClick={() => onReset(tour.id)}>
|
|
135
|
+
{words.markUndone}
|
|
136
|
+
</Button>
|
|
137
|
+
)}
|
|
138
|
+
<Button aria-describedby={titleId} onClick={() => onLaunch(tour.id)}>
|
|
139
|
+
<Play aria-hidden className="size-4" /> {done ? words.replay : words.start}
|
|
140
|
+
</Button>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
</Card>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* ── The page body ─────────────────────────────────────────────────────────── */
|
|
148
|
+
|
|
149
|
+
export interface TourCatalogueProps extends TourCatalogueFilter {
|
|
150
|
+
/** The tours this person may take, already filtered by role and data (§6.3). */
|
|
151
|
+
tours: readonly TourInfo[];
|
|
152
|
+
/** The sections, in the app's order. A tour whose group is not here is not listed. */
|
|
153
|
+
groups: readonly TourGroup[];
|
|
154
|
+
completed: TourCompletions;
|
|
155
|
+
/** The account's `created_at`: a tour shipped after it, untaken, is NEW. Unknown → none is. */
|
|
156
|
+
joined?: string | null;
|
|
157
|
+
/** A filter changed — `{query}`, `{group}`, `{state}`, or all three for "Clear filters". */
|
|
158
|
+
onChange: (patch: Partial<TourCatalogueFilter>) => void;
|
|
159
|
+
onLaunch: (id: string) => void;
|
|
160
|
+
/** "Mark as not done" — `useTourProgress().reset`. */
|
|
161
|
+
onReset?: (id: string) => void;
|
|
162
|
+
/** Above the filter bar: the app's page header with its points tile (§6.1). */
|
|
163
|
+
header?: ReactNode;
|
|
164
|
+
formatDate?: (iso: string) => string;
|
|
165
|
+
/** The search's locale (case folding). Default: the provider's. */
|
|
166
|
+
locale?: string;
|
|
167
|
+
labels?: LabelOverride<TourCatalogueLabels>;
|
|
168
|
+
className?: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const STATE_FILTERS: readonly TourStateFilter[] = ["all", "todo", "done"];
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The tours page's body (docs/tours-harmonization.md §2.1, §3) — keksdose's /tours:
|
|
175
|
+
*
|
|
176
|
+
* - **The filter bar**: a search, a group select and an all / to do / done toggle,
|
|
177
|
+
* stacked below `lg` and side by side from it. Each count is what the OTHER filters
|
|
178
|
+
* leave: the toggle counts the search and the group, each group the search and the
|
|
179
|
+
* state. Under it, while a filter is on, "Showing X of Y tours".
|
|
180
|
+
* - **The sections**: one per group, in the app's order — an h2 with its icon and help
|
|
181
|
+
* line over a `sm:2 / xl:3` grid of `TourCatalogueCard`s. A group the filters empty is
|
|
182
|
+
* hidden; when nothing is left, an empty state with "Clear filters".
|
|
183
|
+
* - **Controlled**: `query`, `group`, `state` and `onChange`, so the app keeps them in its
|
|
184
|
+
* URL (`useTourCatalogueUrlState`).
|
|
185
|
+
*
|
|
186
|
+
* A tour reset by an update (§8) is "to do" for the toggle and the counts.
|
|
187
|
+
*/
|
|
188
|
+
export function TourCatalogue({
|
|
189
|
+
tours,
|
|
190
|
+
groups,
|
|
191
|
+
completed,
|
|
192
|
+
joined,
|
|
193
|
+
query,
|
|
194
|
+
group: groupProp,
|
|
195
|
+
state: stateProp,
|
|
196
|
+
onChange,
|
|
197
|
+
onLaunch,
|
|
198
|
+
onReset,
|
|
199
|
+
header,
|
|
200
|
+
formatDate,
|
|
201
|
+
locale: localeProp,
|
|
202
|
+
labels: labelsProp,
|
|
203
|
+
className,
|
|
204
|
+
}: TourCatalogueProps) {
|
|
205
|
+
const words = useKitLabels("tourCatalogue", DEFAULT_TOUR_CATALOGUE_LABELS, labelsProp);
|
|
206
|
+
const uid = useId();
|
|
207
|
+
const { locale: kitLocale } = useKitFormat();
|
|
208
|
+
const locale = localeProp ?? kitLocale;
|
|
209
|
+
// A group or state the list does not have is no filter at all (an old link).
|
|
210
|
+
const group = groups.some((g) => g.id === groupProp) ? groupProp : null;
|
|
211
|
+
const state = STATE_FILTERS.includes(stateProp) ? stateProp : "all";
|
|
212
|
+
|
|
213
|
+
const listed = tours.filter((t) => groups.some((g) => g.id === t.group));
|
|
214
|
+
const done = (tour: TourInfo) => isTourDone(tour, completed[tour.id]);
|
|
215
|
+
const needle = foldForSearch(query.trim(), locale);
|
|
216
|
+
const matchesQuery = (tour: TourInfo) =>
|
|
217
|
+
!needle || [tour.title, tour.description, tour.about].some((text) => !!text && foldForSearch(text, locale).includes(needle));
|
|
218
|
+
const inState = (tour: TourInfo) => (state === "all" ? true : state === "done" ? done(tour) : !done(tour));
|
|
219
|
+
const inGroup = (tour: TourInfo, id: string | null = group) => id === null || tour.group === id;
|
|
220
|
+
|
|
221
|
+
const found = listed.filter((t) => matchesQuery(t) && inGroup(t));
|
|
222
|
+
const foundDone = found.filter(done).length;
|
|
223
|
+
const visible = found.filter(inState);
|
|
224
|
+
const groupCount = (id: string | null) => listed.filter((t) => matchesQuery(t) && inState(t) && inGroup(t, id)).length;
|
|
225
|
+
const filtering = needle.length > 0 || state !== "all" || group !== null;
|
|
226
|
+
const sections = groups
|
|
227
|
+
.map((g) => ({ ...g, tours: visible.filter((t) => t.group === g.id) }))
|
|
228
|
+
.filter((g) => g.tours.length > 0);
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
<div className={cn("space-y-4", className)}>
|
|
232
|
+
{header}
|
|
233
|
+
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
|
|
234
|
+
<SearchField
|
|
235
|
+
className="lg:max-w-xs lg:flex-1"
|
|
236
|
+
value={query}
|
|
237
|
+
onChange={(value) => onChange({ query: value })}
|
|
238
|
+
aria-label={words.searchLabel}
|
|
239
|
+
placeholder={words.searchPlaceholder}
|
|
240
|
+
/>
|
|
241
|
+
{/* A select, not a chip per group: on a phone those wrap into rows above the list
|
|
242
|
+
they filter, and an app may have ten groups (ka F8). Named, not labelled: a
|
|
243
|
+
floating label makes it taller than the search box beside it (dev #589). */}
|
|
244
|
+
<Select
|
|
245
|
+
className="lg:w-60"
|
|
246
|
+
aria-label={words.groupLabel}
|
|
247
|
+
value={group ?? ""}
|
|
248
|
+
onChange={(event) => onChange({ group: event.target.value || null })}
|
|
249
|
+
>
|
|
250
|
+
<option value="">{words.groupAll(groupCount(null))}</option>
|
|
251
|
+
{groups.map((g) => (
|
|
252
|
+
<option key={g.id} value={g.id}>
|
|
253
|
+
{words.groupOption(g.title, groupCount(g.id))}
|
|
254
|
+
</option>
|
|
255
|
+
))}
|
|
256
|
+
</Select>
|
|
257
|
+
<ToggleGroup
|
|
258
|
+
value={state}
|
|
259
|
+
onChange={(value: TourStateFilter) => onChange({ state: value })}
|
|
260
|
+
aria-label={words.stateLabel}
|
|
261
|
+
className="lg:w-auto"
|
|
262
|
+
options={[
|
|
263
|
+
{ value: "all", label: words.stateAll(found.length) },
|
|
264
|
+
{ value: "todo", label: words.stateTodo(found.length - foundDone) },
|
|
265
|
+
{ value: "done", label: words.stateDone(foundDone) },
|
|
266
|
+
]}
|
|
267
|
+
/>
|
|
268
|
+
</div>
|
|
269
|
+
{filtering && (
|
|
270
|
+
<p role="status" className="-mt-2 text-xs text-muted">
|
|
271
|
+
{words.showing(visible.length, listed.length)}
|
|
272
|
+
</p>
|
|
273
|
+
)}
|
|
274
|
+
|
|
275
|
+
{visible.length === 0 ? (
|
|
276
|
+
<div className="space-y-3">
|
|
277
|
+
<EmptyState title={words.noMatches} hint={words.noMatchesHint} />
|
|
278
|
+
<div className="flex justify-center">
|
|
279
|
+
<Button variant="secondary" onClick={() => onChange({ query: "", group: null, state: "all" })}>
|
|
280
|
+
{words.clearFilters}
|
|
281
|
+
</Button>
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
) : (
|
|
285
|
+
// A GRID, never columns: a section holding one card keeps a cell's width.
|
|
286
|
+
sections.map((section) => (
|
|
287
|
+
<section key={section.id} aria-labelledby={`${uid}-${section.id}`} className="space-y-3">
|
|
288
|
+
<h2 id={`${uid}-${section.id}`} className="flex items-center gap-1.5 text-sm font-semibold text-muted">
|
|
289
|
+
{section.icon != null && (
|
|
290
|
+
<span aria-hidden className="inline-flex shrink-0 [&_svg]:size-4">
|
|
291
|
+
{section.icon}
|
|
292
|
+
</span>
|
|
293
|
+
)}
|
|
294
|
+
{section.title}
|
|
295
|
+
</h2>
|
|
296
|
+
{section.help && <p className="-mt-2 text-xs text-muted">{section.help}</p>}
|
|
297
|
+
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
|
298
|
+
{section.tours.map((tour) => (
|
|
299
|
+
<TourCatalogueCard
|
|
300
|
+
key={tour.id}
|
|
301
|
+
tour={tour}
|
|
302
|
+
completedAt={completed[tour.id]}
|
|
303
|
+
isNew={isTourNew({ since: tour.since, joined, completed: completed[tour.id] !== undefined })}
|
|
304
|
+
onLaunch={onLaunch}
|
|
305
|
+
onReset={onReset}
|
|
306
|
+
formatDate={formatDate}
|
|
307
|
+
labels={labelsProp}
|
|
308
|
+
/>
|
|
309
|
+
))}
|
|
310
|
+
</div>
|
|
311
|
+
</section>
|
|
312
|
+
))
|
|
313
|
+
)}
|
|
314
|
+
</div>
|
|
315
|
+
);
|
|
316
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The tours an app offers, as data (docs/tours-harmonization.md §2.2, §6) — one list for
|
|
5
|
+
* the tours page (`TourCatalogue`) and the top bar's `TourMenu`. The step lists stay the
|
|
6
|
+
* app's own: the kit's engine runs them (`useTour().start`), and this describes them.
|
|
7
|
+
*
|
|
8
|
+
* The kit takes finished strings, never i18n keys (§6.3, kk K-1). Role- and data-gated
|
|
9
|
+
* tours are filtered by the app before they come in; every count counts what it is given
|
|
10
|
+
* (§6.3, kk K-2, ka F6).
|
|
11
|
+
*/
|
|
12
|
+
export interface TourInfo {
|
|
13
|
+
/** Stable id: what `onLaunch` gets, the progress key, `?tour=`. */
|
|
14
|
+
id: string;
|
|
15
|
+
title: string;
|
|
16
|
+
/** The one-liner under the title (and what the menu could carry). */
|
|
17
|
+
description?: string;
|
|
18
|
+
/** The longer account: which screens it visits, what it explains. Shown from `sm`. */
|
|
19
|
+
about?: string;
|
|
20
|
+
/** The tour's icon, drawn at 16px in the brand colour. */
|
|
21
|
+
icon?: ReactNode;
|
|
22
|
+
/** The id of its `TourGroup`: the section it is listed under. */
|
|
23
|
+
group: string;
|
|
24
|
+
/** The page the tour starts on — `TourMenu`'s "On this page". */
|
|
25
|
+
route?: string;
|
|
26
|
+
/** The day it shipped (ISO `YYYY-MM-DD`): what makes it NEW (§6.4). Without it, never NEW. */
|
|
27
|
+
since?: string;
|
|
28
|
+
/** The last update or fix (§8): a completion from before `updated.date` no longer counts. */
|
|
29
|
+
updated?: TourUpdate;
|
|
30
|
+
/** How many steps it has. Left out, the card shows no step count. */
|
|
31
|
+
steps?: number;
|
|
32
|
+
/** The points it awards (§6.1). Left out, the card draws no chip (never "0 points"). */
|
|
33
|
+
points?: number;
|
|
34
|
+
/** About how long it takes, in minutes — worded by the kit ("About 3 min", §6.5). */
|
|
35
|
+
duration?: number;
|
|
36
|
+
/** A chip beside NEW in the app's words — kastlan's practice mode (§6.5, ka F1). */
|
|
37
|
+
badge?: ReactNode;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A tour's last update or fix (§8). One at a time: a later update replaces it. */
|
|
41
|
+
export interface TourUpdate {
|
|
42
|
+
/** The day it shipped (ISO `YYYY-MM-DD`). */
|
|
43
|
+
date: string;
|
|
44
|
+
/** Why, in a short sentence in the app's words: "The import screen moved; the tour follows it now."
|
|
45
|
+
* Only shown (the card's reset line); the reset itself reads `date` alone, so an app that
|
|
46
|
+
* keeps a reason KEY on its tour definitions passes those to `complete(tour)` as they are. */
|
|
47
|
+
reason?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A section of the tours page, in the app's words and order (§2.3). An empty one is hidden. */
|
|
51
|
+
export interface TourGroup {
|
|
52
|
+
id: string;
|
|
53
|
+
title: string;
|
|
54
|
+
/** The help line under the heading. */
|
|
55
|
+
help?: string;
|
|
56
|
+
/** The heading's icon, drawn at 16px. */
|
|
57
|
+
icon?: ReactNode;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Which tours are completed and when: tour id → the ISO timestamp of the completion, or
|
|
62
|
+
* `null` for one recorded without a date (kastlan's old id list, §6.2; stored as `""`,
|
|
63
|
+
* §9.6.6). A tour that is not a key is untaken.
|
|
64
|
+
*/
|
|
65
|
+
export type TourCompletions = Readonly<Record<string, string | null>>;
|
|
66
|
+
|
|
67
|
+
/** The calendar day of an ISO date or timestamp — `"2026-10-17T08:00:00Z"` → `"2026-10-17"`. */
|
|
68
|
+
const dayOf = (iso: string) => iso.slice(0, 10);
|
|
69
|
+
|
|
70
|
+
/** What a reset reads of a tour (§8): the update's day, nothing else. */
|
|
71
|
+
type TourUpdated = { updated?: { date: string } | null };
|
|
72
|
+
|
|
73
|
+
const BARE_DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
74
|
+
|
|
75
|
+
/** The start of an ISO day (`YYYY-MM-DD`) in THIS device's time zone, as an instant. */
|
|
76
|
+
function startOfLocalDay(day: string): number {
|
|
77
|
+
const [y, m, d] = dayOf(day).split("-").map(Number);
|
|
78
|
+
return new Date(y, m - 1, d).getTime();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A completion as an instant: a timestamp as written, a bare day from its local start. */
|
|
82
|
+
const instantOf = (iso: string) => (BARE_DAY.test(iso) ? startOfLocalDay(iso) : Date.parse(iso));
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Is this tour NEW for the person looking at it (§2.4, §6.4)? It shipped AFTER the
|
|
86
|
+
* account was opened — compared by calendar day, so a tour shipped on the joining day was
|
|
87
|
+
* already there to be found — and it is not taken yet. No `since`, or no `joined` date,
|
|
88
|
+
* is never NEW: a pill that appears late is better than one that flickers, and a first
|
|
89
|
+
* tour shipped with a feature should not flash NEW at every older account (KS, ka F7).
|
|
90
|
+
*
|
|
91
|
+
* `completed` is whether the tour was EVER completed — a tour reset by an update (§8) is
|
|
92
|
+
* not NEW again; it carries the "Updated" pill instead, and `since` stays its first ship
|
|
93
|
+
* date.
|
|
94
|
+
*/
|
|
95
|
+
export function isTourNew({
|
|
96
|
+
since,
|
|
97
|
+
joined,
|
|
98
|
+
completed,
|
|
99
|
+
}: {
|
|
100
|
+
since?: string | null;
|
|
101
|
+
/** The account's `created_at` (a date or a timestamp); unknown → never NEW. */
|
|
102
|
+
joined?: string | null;
|
|
103
|
+
completed?: boolean;
|
|
104
|
+
}): boolean {
|
|
105
|
+
return !!since && !!joined && dayOf(since) > dayOf(joined) && !completed;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Was this completion reset by an update (§8)? True when the tour carries `updated` and
|
|
110
|
+
* the completion happened BEFORE the update day began on this device — instants compared:
|
|
111
|
+
* the completion's timestamp against the start of `updated.date` in local time, so one
|
|
112
|
+
* taken at 00:30 in Zurich on the update day counts (its UTC day is the day before) — or
|
|
113
|
+
* has no date at all, which predates any update. A tour never completed (`completedAt`
|
|
114
|
+
* undefined) is never reset: for that person there is nothing to reset. The check itself
|
|
115
|
+
* deletes nothing; a new `complete(tour)` replaces the old date.
|
|
116
|
+
*/
|
|
117
|
+
export function isTourReset(tour: TourUpdated, completedAt: string | null | undefined): boolean {
|
|
118
|
+
if (completedAt === undefined || !tour.updated) return false;
|
|
119
|
+
return completedAt === null || instantOf(completedAt) < startOfLocalDay(tour.updated.date);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Does this completion count — completed, and not reset by a later update (§8)? */
|
|
123
|
+
export function isTourDone(tour: TourUpdated, completedAt: string | null | undefined): boolean {
|
|
124
|
+
return completedAt !== undefined && !isTourReset(tour, completedAt);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* How many of `tours` count as done (§8): the header's "x of y". A tour reset by an update
|
|
129
|
+
* does not count until it is taken again, as on its card.
|
|
130
|
+
*/
|
|
131
|
+
export function doneCount(tours: readonly ({ id: string } & TourUpdated)[], completed: TourCompletions): number {
|
|
132
|
+
return tours.filter((tour) => isTourDone(tour, completed[tour.id])).length;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The points earned on `tours` (§6.1, §8): the sum of each done tour's `points`. A reset
|
|
137
|
+
* tour's points are not earned until it is taken again; a tour without points adds none.
|
|
138
|
+
*/
|
|
139
|
+
export function earnedPoints(
|
|
140
|
+
tours: readonly ({ id: string; points?: number | null } & TourUpdated)[],
|
|
141
|
+
completed: TourCompletions,
|
|
142
|
+
): number {
|
|
143
|
+
return tours.reduce((sum, tour) => sum + (isTourDone(tour, completed[tour.id]) ? (tour.points ?? 0) : 0), 0);
|
|
144
|
+
}
|
package/src/tour/tour.tsx
CHANGED
|
@@ -17,6 +17,8 @@ import { sameRect } from "../hooks/use-anchored-rect";
|
|
|
17
17
|
import { dirOf, horizontalStep } from "../lib/direction";
|
|
18
18
|
import type { Direction } from "../lib/direction";
|
|
19
19
|
import { DEFAULT_TOUR_LABELS } from "./tour-labels";
|
|
20
|
+
import type { TourInfo } from "./tour-info";
|
|
21
|
+
import { completeStored, rememberMigrate, type UseTourProgressOptions } from "./use-tour-progress";
|
|
20
22
|
|
|
21
23
|
/**
|
|
22
24
|
* `start`/`end` follow the reading direction (`end` is the right side in LTR and the
|
|
@@ -67,16 +69,34 @@ export { DEFAULT_TOUR_LABELS };
|
|
|
67
69
|
|
|
68
70
|
interface StartOptions {
|
|
69
71
|
/** Stable id of the tour, surfaced as `activeId` so the app can mirror it to
|
|
70
|
-
* the URL (deep-link / evaluation tracking) and record completion.
|
|
72
|
+
* the URL (deep-link / evaluation tracking) and record completion. Defaults to
|
|
73
|
+
* `tour.id`. */
|
|
71
74
|
id?: string;
|
|
75
|
+
/** The tour being started, as the app lists it (docs/tours-harmonization.md §9.6.1).
|
|
76
|
+
* When its last step is finished, the provider records the completion in its
|
|
77
|
+
* `progress` and hands this to `onComplete`. */
|
|
78
|
+
tour?: TourInfo;
|
|
72
79
|
/** Start at this step index instead of 0 (clamped) — for URL/deep-link resume. */
|
|
73
80
|
startIndex?: number;
|
|
74
81
|
/** Fires synchronously when the tour starts (e.g. enable a mock interceptor). */
|
|
75
82
|
onStart?: () => void;
|
|
83
|
+
/** After the last step: runs after the provider's progress write and `onComplete`. */
|
|
76
84
|
onFinish?: () => void;
|
|
77
85
|
onSkip?: () => void;
|
|
78
86
|
}
|
|
79
87
|
|
|
88
|
+
/** What `TourProvider`'s `onComplete` learns besides the tour (§9.1, §9.6). */
|
|
89
|
+
export interface TourCompletion {
|
|
90
|
+
/**
|
|
91
|
+
* `true` when the completion counts anew (the first time, or the first time after the
|
|
92
|
+
* tour's update, §8): the moment for the app's points toast. `false` for a replay, and
|
|
93
|
+
* when the stored text is in an old shape no `migrate` reads: nothing is overwritten
|
|
94
|
+
* then, and the error goes to `console.error` (keksdose B-1). `undefined` when the provider has no `progress` or the start carried no `tour`, so
|
|
95
|
+
* nothing was recorded.
|
|
96
|
+
*/
|
|
97
|
+
awarded: boolean | undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
80
100
|
interface TourContextValue {
|
|
81
101
|
active: boolean;
|
|
82
102
|
/** Id of the running tour (from {@link StartOptions.id}), else null. */
|
|
@@ -129,6 +149,11 @@ export function useTourOptional(): TourContextValue | null {
|
|
|
129
149
|
return useContext(TourContext);
|
|
130
150
|
}
|
|
131
151
|
|
|
152
|
+
/** The kit's dev switch (as number-input's): a development build, never under vitest. */
|
|
153
|
+
function devWarnings(): boolean {
|
|
154
|
+
return Boolean(import.meta.env?.DEV) && !import.meta.env?.VITEST;
|
|
155
|
+
}
|
|
156
|
+
|
|
132
157
|
/**
|
|
133
158
|
* Provides the guided-tour engine: a dimmed spotlight overlay + step card driven
|
|
134
159
|
* by a list of {@link TourStep}s. Domain-free — the app supplies the steps (copy,
|
|
@@ -136,13 +161,32 @@ export function useTourOptional(): TourContextValue | null {
|
|
|
136
161
|
* Mount once inside the router; start a tour from anywhere via {@link useTour},
|
|
137
162
|
* which also exposes step control (`next`/`prev`/`goToStep`) and the current
|
|
138
163
|
* `index`/`total` for URL mirroring, `awaitClick` and deep-link resume.
|
|
164
|
+
*
|
|
165
|
+
* **Completion in one place** (docs/tours-harmonization.md §9.6): give the provider the
|
|
166
|
+
* app's `progress` (the same `{storageKey, migrate}` as `useTourProgress`) and start each
|
|
167
|
+
* tour with `start(steps, {tour})`. When the last step is finished the provider writes the
|
|
168
|
+
* completion ONCE, through the store every `useTourProgress` reader of the key shares
|
|
169
|
+
* (never a second copy), then calls `onComplete(tour, {awarded})`, then the start's own
|
|
170
|
+
* `onFinish`. A skip or `stop()` records nothing and calls neither. The app removes its
|
|
171
|
+
* own `complete()` from `onFinish`, or that second write answers `false`.
|
|
172
|
+
*
|
|
173
|
+
* Both props are optional: without `progress` or a `tour` on the start, `onComplete`
|
|
174
|
+
* still fires, with `awarded: undefined` (and `tour` as given, possibly `undefined`). A
|
|
175
|
+
* new `storageKey` (an account switch on a shared PC) is followed on the next
|
|
176
|
+
* completion, without a remount.
|
|
139
177
|
*/
|
|
140
178
|
export function TourProvider({
|
|
141
179
|
children,
|
|
142
180
|
labels,
|
|
181
|
+
progress,
|
|
182
|
+
onComplete,
|
|
143
183
|
}: {
|
|
144
184
|
children: ReactNode;
|
|
145
185
|
labels?: LabelOverride<TourLabels>;
|
|
186
|
+
/** The app's tour progress: the same options its `useTourProgress` readers use. */
|
|
187
|
+
progress?: UseTourProgressOptions;
|
|
188
|
+
/** A tour's last step was finished; see {@link TourCompletion}. */
|
|
189
|
+
onComplete?: (tour: TourInfo | undefined, completion: TourCompletion) => void;
|
|
146
190
|
}) {
|
|
147
191
|
// `labels` over the provider's `tour` over English. Resolved once here and handed
|
|
148
192
|
// down: the step card is portalled, but context crosses portals anyway — this is
|
|
@@ -152,6 +196,20 @@ export function TourProvider({
|
|
|
152
196
|
const [index, setIndex] = useState(0);
|
|
153
197
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
154
198
|
const optsRef = useRef<StartOptions>({});
|
|
199
|
+
// The latest props, read at the finish: a new `storageKey` or callback needs no remount.
|
|
200
|
+
const progressRef = useRef(progress);
|
|
201
|
+
const onCompleteRef = useRef(onComplete);
|
|
202
|
+
useLayoutEffect(() => {
|
|
203
|
+
progressRef.current = progress;
|
|
204
|
+
onCompleteRef.current = onComplete;
|
|
205
|
+
});
|
|
206
|
+
// The provider's migrate becomes the key's, for readers that pass none (keksdose B-2).
|
|
207
|
+
const storageKey = progress?.storageKey;
|
|
208
|
+
const migrate = progress?.migrate;
|
|
209
|
+
useLayoutEffect(() => {
|
|
210
|
+
if (storageKey !== undefined) rememberMigrate(storageKey, migrate);
|
|
211
|
+
}, [storageKey, migrate]);
|
|
212
|
+
const warnedNoTourRef = useRef(false);
|
|
155
213
|
// Live mirrors so the step-control callbacks can stay stable (identity-safe for
|
|
156
214
|
// consumers) while still reading the current steps/index. Synced in a layout
|
|
157
215
|
// effect — after the commit, before paint and before any passive effect or event
|
|
@@ -169,7 +227,7 @@ export function TourProvider({
|
|
|
169
227
|
optsRef.current = opts ?? {};
|
|
170
228
|
optsRef.current.onStart?.();
|
|
171
229
|
setIndex(Math.min(Math.max(0, opts?.startIndex ?? 0), Math.max(0, s.length - 1)));
|
|
172
|
-
setActiveId(opts?.id ?? null);
|
|
230
|
+
setActiveId(opts?.id ?? opts?.tour?.id ?? null);
|
|
173
231
|
setSteps(s);
|
|
174
232
|
}, []);
|
|
175
233
|
const stop = useCallback(() => {
|
|
@@ -177,7 +235,30 @@ export function TourProvider({
|
|
|
177
235
|
setActiveId(null);
|
|
178
236
|
}, []);
|
|
179
237
|
const finish = useCallback(() => {
|
|
180
|
-
|
|
238
|
+
// Once per tour: a second "Done" before the overlay unmounts records nothing more.
|
|
239
|
+
if (!stepsRef.current) return;
|
|
240
|
+
stepsRef.current = null;
|
|
241
|
+
const { tour, onFinish } = optsRef.current;
|
|
242
|
+
const store = progressRef.current;
|
|
243
|
+
// Exactly one write per completion (§9.6.2), so `awarded` is what `complete(tour)` answered.
|
|
244
|
+
let awarded: boolean | undefined;
|
|
245
|
+
if (store && tour) {
|
|
246
|
+
try {
|
|
247
|
+
awarded = completeStored(store.storageKey, store.migrate, tour);
|
|
248
|
+
} catch (error) {
|
|
249
|
+
// A stored text no migrate reads is left as it is (keksdose B-1); the tour still ends.
|
|
250
|
+
console.error(error);
|
|
251
|
+
awarded = false;
|
|
252
|
+
}
|
|
253
|
+
} else if (store && !tour && devWarnings() && !warnedNoTourRef.current) {
|
|
254
|
+
warnedNoTourRef.current = true;
|
|
255
|
+
console.warn(
|
|
256
|
+
"[ui-kit] TourProvider has `progress`, but a finished tour's start carried no `tour`: " +
|
|
257
|
+
"nothing was recorded and `awarded` is undefined. Start it with `start(steps, { tour })`.",
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
onCompleteRef.current?.(tour, { awarded });
|
|
261
|
+
onFinish?.();
|
|
181
262
|
setSteps(null);
|
|
182
263
|
setActiveId(null);
|
|
183
264
|
}, []);
|