@atelic-action/ui 0.17.2 → 1.0.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 +428 -44
- package/bin/atelic-check-base-path.ts +5 -0
- package/bin/atelic-clean-sitemap.ts +5 -0
- package/bin/atelic-contact-canary.ts +5 -0
- package/bin/atelic-print-sheets.sh +1 -1
- package/bin/atelic-seal-document.mjs +1 -1
- package/bin/atelic-serve-static.ts +1 -1
- package/dist/gate/index.d.ts +1 -1
- package/dist/gate/index.js +1 -1
- package/dist/gate/middleware.d.ts +4 -1
- package/dist/gate/middleware.js +14 -5
- package/dist/gate/wall.d.ts +25 -0
- package/dist/gate/wall.js +41 -2
- package/dist/routing/basePath.d.ts +24 -0
- package/dist/routing/basePath.js +54 -0
- package/dist/vite/index.d.ts +3 -2
- package/dist/vite/index.js +3 -2
- package/dist/vite/marketing-config.d.ts +50 -0
- package/dist/vite/marketing-config.js +83 -0
- package/package.json +164 -5
- package/src/app/AppProvider.tsx +41 -0
- package/src/app/AuthGuard.tsx +27 -0
- package/src/app/CardErrorBoundary.tsx +39 -0
- package/src/app/ErrorFallback.tsx +39 -0
- package/src/app/GoogleGlyph.tsx +22 -0
- package/src/app/config.ts +54 -0
- package/src/app/configure.ts +18 -0
- package/src/app/context/AppContext.tsx +52 -0
- package/src/app/context/SidebarContext.tsx +124 -0
- package/src/app/context/UserContext.tsx +139 -0
- package/src/app/hooks/useDebounce.ts +12 -0
- package/src/app/hooks/useIsDesktop.ts +31 -0
- package/src/app/hooks/useWakeLock.ts +49 -0
- package/src/app/index.ts +64 -0
- package/src/app/layout/ActionButton.tsx +67 -0
- package/src/app/layout/AppLayout.tsx +110 -0
- package/src/app/layout/AppPage.tsx +268 -0
- package/src/app/layout/Breadcrumbs.tsx +57 -0
- package/src/app/layout/DesktopRail.tsx +168 -0
- package/src/app/layout/FormActionButtons.tsx +69 -0
- package/src/app/layout/MobileMenu.tsx +156 -0
- package/src/app/layout/MobileTabBar.tsx +43 -0
- package/src/app/layout/PageError.tsx +30 -0
- package/src/app/layout/PageSkeleton.tsx +65 -0
- package/src/app/layout/SectionCards.tsx +33 -0
- package/src/app/layout/UserMenu.tsx +104 -0
- package/src/app/layout/identity.ts +22 -0
- package/src/app/layout/index.ts +20 -0
- package/src/app/layout/nav.ts +52 -0
- package/src/app/lib/dates.ts +57 -0
- package/src/app/lib/env.ts +37 -0
- package/src/app/lib/format.ts +61 -0
- package/src/app/lib/http.ts +205 -0
- package/src/app/lib/lastAuth.ts +45 -0
- package/src/app/lib/oauth.ts +32 -0
- package/src/app/lib/publicRoutes.ts +33 -0
- package/src/app/lib/queryClient.ts +38 -0
- package/src/app/lib/search.ts +17 -0
- package/src/app/lib/session.ts +36 -0
- package/src/app/lib/tokenRefresh.ts +137 -0
- package/src/app/lib/transformKeys.ts +60 -0
- package/src/app/lib/units.ts +228 -0
- package/src/app/lib/unwrap.ts +48 -0
- package/src/app/lib/utils.ts +10 -0
- package/src/app/stores/authStore.ts +80 -0
- package/src/app/stores/storage.ts +40 -0
- package/src/app/stores/unitsStore.ts +54 -0
- package/src/app/types/index.ts +72 -0
- package/src/app/types/query.ts +11 -0
- package/src/app/ui/accordion.tsx +54 -0
- package/src/app/ui/alert-dialog.tsx +114 -0
- package/src/app/ui/alert.tsx +49 -0
- package/src/app/ui/avatar.tsx +45 -0
- package/src/app/ui/badge-list.tsx +51 -0
- package/src/app/ui/badge.tsx +33 -0
- package/src/app/ui/button.tsx +49 -0
- package/src/app/ui/callout.tsx +22 -0
- package/src/app/ui/card.tsx +55 -0
- package/src/app/ui/checkbox.tsx +26 -0
- package/src/app/ui/clear-button.tsx +42 -0
- package/src/app/ui/command.tsx +140 -0
- package/src/app/ui/country-dropdown.tsx +184 -0
- package/src/app/ui/dialog.tsx +104 -0
- package/src/app/ui/dropdown-menu.tsx +185 -0
- package/src/app/ui/form.tsx +172 -0
- package/src/app/ui/index.ts +36 -0
- package/src/app/ui/input.tsx +22 -0
- package/src/app/ui/label.tsx +19 -0
- package/src/app/ui/option-select.tsx +56 -0
- package/src/app/ui/pagination.tsx +90 -0
- package/src/app/ui/popover.tsx +29 -0
- package/src/app/ui/progress.tsx +25 -0
- package/src/app/ui/search-input.tsx +63 -0
- package/src/app/ui/select.tsx +151 -0
- package/src/app/ui/separator.tsx +24 -0
- package/src/app/ui/sheet.tsx +119 -0
- package/src/app/ui/slider.tsx +23 -0
- package/src/app/ui/switch.tsx +27 -0
- package/src/app/ui/textarea.tsx +21 -0
- package/src/app/ui/tooltip.tsx +32 -0
- package/src/artifact/ArtifactShell.tsx +2 -2
- package/src/artifact/Gate.tsx +2 -2
- package/src/artifact/Writeup.tsx +7 -7
- package/src/chrome/SiteShell.tsx +141 -0
- package/src/chrome/index.ts +2 -0
- package/src/components/Disclosure.tsx +80 -14
- package/src/components/GateLogin.tsx +1 -1
- package/src/components/Watermark.tsx +1 -1
- package/src/components/index.ts +6 -2
- package/src/gate/index.ts +1 -1
- package/src/gate/middleware.ts +17 -6
- package/src/gate/wall.ts +60 -2
- package/src/lib/basePathAudit.ts +96 -0
- package/src/lib/displayDate.ts +25 -0
- package/src/lib/honeypot.ts +11 -0
- package/src/lib/hours.ts +28 -0
- package/src/lib/sitemap.ts +34 -0
- package/src/lib/trackEvent.ts +26 -0
- package/src/lib/useOpenHeight.ts +28 -0
- package/src/primitives/Button.tsx +3 -1
- package/src/primitives/Chip.tsx +3 -1
- package/src/primitives/ImageFrame.tsx +48 -0
- package/src/primitives/ProseBlocks.tsx +67 -0
- package/src/primitives/QuoteCard.tsx +20 -0
- package/src/primitives/StarRow.tsx +37 -0
- package/src/primitives/TickList.tsx +24 -0
- package/src/primitives/index.ts +5 -0
- package/src/routing/BasePathProvider.tsx +47 -0
- package/src/routing/basePath.ts +57 -0
- package/src/routing/index.ts +8 -0
- package/src/scripts/check-base-path.ts +130 -0
- package/src/scripts/clean-sitemap.ts +27 -0
- package/src/scripts/contact-canary.ts +98 -0
- package/src/scripts/serve-static.ts +29 -3
- package/src/sections/BlogIndex.tsx +57 -0
- package/src/sections/BlogPost.tsx +65 -0
- package/src/sections/CTABanner.tsx +93 -0
- package/src/sections/CTASection.tsx +60 -0
- package/src/sections/ComparisonTable.tsx +114 -0
- package/src/sections/ContactForm.tsx +163 -0
- package/src/sections/ContactInfoCard.tsx +92 -0
- package/src/sections/CredentialList.tsx +28 -0
- package/src/sections/DeviceShowcase.tsx +39 -0
- package/src/sections/ExpandList.tsx +58 -0
- package/src/sections/FAQBrowser.tsx +283 -0
- package/src/sections/FeatureDetail.tsx +60 -0
- package/src/sections/FeatureGrid.tsx +83 -0
- package/src/sections/Gallery.tsx +84 -0
- package/src/sections/Hero.tsx +110 -0
- package/src/sections/HoursTable.tsx +23 -0
- package/src/sections/JumpRail.tsx +35 -0
- package/src/sections/LegalPage.tsx +43 -0
- package/src/sections/LocationSection.tsx +66 -0
- package/src/sections/Locations.tsx +71 -0
- package/src/sections/LogoMarquee.tsx +27 -0
- package/src/sections/MapEmbed.tsx +75 -0
- package/src/sections/MediaBand.tsx +20 -0
- package/src/sections/MenuList.tsx +44 -0
- package/src/sections/PageHero.tsx +25 -0
- package/src/sections/PressQuotes.tsx +51 -0
- package/src/sections/PricingBand.tsx +43 -0
- package/src/sections/PricingCard.tsx +36 -0
- package/src/sections/ProcessSteps.tsx +45 -0
- package/src/sections/ProfileCards.tsx +73 -0
- package/src/sections/RevealSlider.tsx +156 -0
- package/src/sections/ReviewCarousel.tsx +223 -0
- package/src/sections/ReviewWall.tsx +220 -0
- package/src/sections/SocialStrip.tsx +83 -0
- package/src/sections/SplitSection.tsx +56 -0
- package/src/sections/index.ts +76 -0
- package/src/sections/siteConfig.ts +201 -0
- package/src/styles/app.css +673 -0
- package/src/styles/artifact.css +472 -399
- package/src/styles/base.css +49 -47
- package/src/styles/chrome.css +71 -71
- package/src/styles/components.css +150 -140
- package/src/styles/gate.css +29 -29
- package/src/styles/primitives.css +190 -25
- package/src/styles/sections.css +2652 -0
- package/src/styles/star-rating.css +2 -2
- package/src/vite/index.ts +9 -2
- package/src/vite/marketing-config.ts +128 -0
- package/src/components/Collapsible.tsx +0 -43
- /package/src/scripts/{build-gate-payload.mjs → seal-document.mjs} +0 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit display for logged measurements.
|
|
3
|
+
*
|
|
4
|
+
* The API stores every measurement in one canonical unit (kg, ml, L, g, °C).
|
|
5
|
+
* The UI converts at the presentation layer so a measure can be read and typed
|
|
6
|
+
* in either system without the wire format ever changing.
|
|
7
|
+
*
|
|
8
|
+
* Two levels:
|
|
9
|
+
* - a **scale** is what a single field is stored in (`massKg`, `volumeMl`, …)
|
|
10
|
+
* - a **measure** is the preference group a scale belongs to (`bodyMass`,
|
|
11
|
+
* `volume`, …). Switching a measure switches every scale under it, so
|
|
12
|
+
* weight before and weight after can never disagree.
|
|
13
|
+
*
|
|
14
|
+
* Round-trip warning: a value converted out and back does not always land on
|
|
15
|
+
* itself (84.458 kg reads 186.2 lb, which converts back to 84.46 kg). Callers
|
|
16
|
+
* must send the untouched canonical value for fields the user did not edit
|
|
17
|
+
* rather than re-deriving it from the display string.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type UnitSystem = "metric" | "imperial";
|
|
21
|
+
|
|
22
|
+
/** Preference groups. One choice each, persisted in `unitsStore`. */
|
|
23
|
+
export type MeasureId = "bodyMass" | "volume" | "foodMass" | "temperature";
|
|
24
|
+
|
|
25
|
+
/** A system per measure, as the api stores it on the person and the store keeps it. */
|
|
26
|
+
export type UnitPreferences = Record<MeasureId, UnitSystem>;
|
|
27
|
+
|
|
28
|
+
/** What a given field is stored in on the API. */
|
|
29
|
+
export type ScaleId = "massKg" | "volumeMl" | "volumeL" | "massG" | "tempC" | "percent" | "count";
|
|
30
|
+
|
|
31
|
+
export interface UnitDef {
|
|
32
|
+
/** Shown beside the value and in the dropdown ("kg", "fl oz", "°F"). */
|
|
33
|
+
label: string;
|
|
34
|
+
/** Decimal places used when rendering a canonical value in this unit. */
|
|
35
|
+
decimals: number;
|
|
36
|
+
/** Canonical value in this unit. */
|
|
37
|
+
from: (canonical: number) => number;
|
|
38
|
+
/** This unit back to canonical. */
|
|
39
|
+
to: (value: number) => number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface Scale {
|
|
43
|
+
/** The preference group, or null when the field has no unit choice. */
|
|
44
|
+
measure: MeasureId | null;
|
|
45
|
+
metric: UnitDef;
|
|
46
|
+
imperial: UnitDef;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const POUNDS_PER_KG = 2.2046226218;
|
|
50
|
+
const FL_OZ_PER_ML = 0.0338140227;
|
|
51
|
+
const OUNCES_PER_GRAM = 0.0352739619;
|
|
52
|
+
|
|
53
|
+
const identity = (value: number): number => value;
|
|
54
|
+
|
|
55
|
+
const SCALES: Record<ScaleId, Scale> = {
|
|
56
|
+
massKg: {
|
|
57
|
+
measure: "bodyMass",
|
|
58
|
+
metric: { label: "kg", decimals: 1, from: identity, to: identity },
|
|
59
|
+
imperial: {
|
|
60
|
+
label: "lb",
|
|
61
|
+
decimals: 1,
|
|
62
|
+
from: (kg) => kg * POUNDS_PER_KG,
|
|
63
|
+
to: (lb) => lb / POUNDS_PER_KG,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
volumeMl: {
|
|
67
|
+
measure: "volume",
|
|
68
|
+
metric: { label: "ml", decimals: 0, from: identity, to: identity },
|
|
69
|
+
imperial: {
|
|
70
|
+
label: "fl oz",
|
|
71
|
+
decimals: 0,
|
|
72
|
+
from: (ml) => ml * FL_OZ_PER_ML,
|
|
73
|
+
to: (flOz) => flOz / FL_OZ_PER_ML,
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
volumeL: {
|
|
77
|
+
measure: "volume",
|
|
78
|
+
metric: { label: "L", decimals: 1, from: identity, to: identity },
|
|
79
|
+
imperial: {
|
|
80
|
+
label: "fl oz",
|
|
81
|
+
decimals: 0,
|
|
82
|
+
from: (liters) => liters * 1000 * FL_OZ_PER_ML,
|
|
83
|
+
to: (flOz) => flOz / FL_OZ_PER_ML / 1000,
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
massG: {
|
|
87
|
+
measure: "foodMass",
|
|
88
|
+
metric: { label: "g", decimals: 0, from: identity, to: identity },
|
|
89
|
+
imperial: {
|
|
90
|
+
label: "oz",
|
|
91
|
+
decimals: 1,
|
|
92
|
+
from: (grams) => grams * OUNCES_PER_GRAM,
|
|
93
|
+
to: (oz) => oz / OUNCES_PER_GRAM,
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
tempC: {
|
|
97
|
+
measure: "temperature",
|
|
98
|
+
metric: { label: "°C", decimals: 1, from: identity, to: identity },
|
|
99
|
+
imperial: {
|
|
100
|
+
label: "°F",
|
|
101
|
+
decimals: 0,
|
|
102
|
+
from: (celsius) => celsius * 1.8 + 32,
|
|
103
|
+
to: (fahrenheit) => (fahrenheit - 32) / 1.8,
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
percent: {
|
|
107
|
+
measure: null,
|
|
108
|
+
metric: { label: "%", decimals: 0, from: identity, to: identity },
|
|
109
|
+
imperial: { label: "%", decimals: 0, from: identity, to: identity },
|
|
110
|
+
},
|
|
111
|
+
count: {
|
|
112
|
+
measure: null,
|
|
113
|
+
metric: { label: "", decimals: 0, from: identity, to: identity },
|
|
114
|
+
imperial: { label: "", decimals: 0, from: identity, to: identity },
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** The preference group a scale answers to, or null when it has no choice. */
|
|
119
|
+
export function measureOf(scale: ScaleId): MeasureId | null {
|
|
120
|
+
return SCALES[scale].measure;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The unit a scale renders in under the given system. */
|
|
124
|
+
export function unitFor(scale: ScaleId, system: UnitSystem): UnitDef {
|
|
125
|
+
return SCALES[scale][system];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** True when the field offers a real choice worth a dropdown. */
|
|
129
|
+
export function hasUnitChoice(scale: ScaleId): boolean {
|
|
130
|
+
return SCALES[scale].measure !== null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Dropdown options for a scale, metric first. Empty when there is no choice. */
|
|
134
|
+
export function unitOptions(scale: ScaleId): { value: UnitSystem; label: string }[] {
|
|
135
|
+
if (!hasUnitChoice(scale)) return [];
|
|
136
|
+
return [
|
|
137
|
+
{ value: "metric", label: SCALES[scale].metric.label },
|
|
138
|
+
{ value: "imperial", label: SCALES[scale].imperial.label },
|
|
139
|
+
];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Round to a unit's display precision without trailing-zero padding. */
|
|
143
|
+
function round(value: number, decimals: number): number {
|
|
144
|
+
const factor = 10 ** decimals;
|
|
145
|
+
return Math.round(value * factor) / factor;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* A canonical value rendered for display, as the string an input holds.
|
|
150
|
+
* Returns "" for null/undefined/NaN so an empty field stays empty.
|
|
151
|
+
*/
|
|
152
|
+
export function toDisplay(
|
|
153
|
+
canonical: number | null | undefined,
|
|
154
|
+
scale: ScaleId,
|
|
155
|
+
system: UnitSystem,
|
|
156
|
+
): string {
|
|
157
|
+
if (canonical === null || canonical === undefined || Number.isNaN(canonical)) return "";
|
|
158
|
+
const unit = unitFor(scale, system);
|
|
159
|
+
return String(round(unit.from(canonical), unit.decimals));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* A typed display string converted back to the canonical unit, or null when
|
|
164
|
+
* the string does not parse. Canonical values keep two more decimals than the
|
|
165
|
+
* display so a conversion never throws away what the user typed.
|
|
166
|
+
*/
|
|
167
|
+
export function toCanonical(display: string, scale: ScaleId, system: UnitSystem): number | null {
|
|
168
|
+
const parsed = parseFloat(display);
|
|
169
|
+
if (Number.isNaN(parsed)) return null;
|
|
170
|
+
const unit = unitFor(scale, system);
|
|
171
|
+
return round(unit.to(parsed), unit.decimals + 2);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Restate a display string from one system in another, preserving what the
|
|
176
|
+
* user typed as closely as the target precision allows. Used when a unit
|
|
177
|
+
* dropdown changes while a field holds an unsaved edit.
|
|
178
|
+
*/
|
|
179
|
+
export function convertDisplay(
|
|
180
|
+
display: string,
|
|
181
|
+
scale: ScaleId,
|
|
182
|
+
from: UnitSystem,
|
|
183
|
+
to: UnitSystem,
|
|
184
|
+
): string {
|
|
185
|
+
if (from === to) return display;
|
|
186
|
+
const canonical = toCanonical(display, scale, from);
|
|
187
|
+
if (canonical === null) return display;
|
|
188
|
+
return toDisplay(canonical, scale, to);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Kilograms to pounds, rounded to two decimals. */
|
|
192
|
+
export function kgToPounds(kg: number): number {
|
|
193
|
+
return round(kg * POUNDS_PER_KG, 2);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Pounds to kilograms, rounded to two decimals. */
|
|
197
|
+
export function poundsToKg(pounds: number): number {
|
|
198
|
+
return round(pounds / POUNDS_PER_KG, 2);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const CM_PER_INCH = 2.54;
|
|
202
|
+
|
|
203
|
+
/** Feet and inches to centimeters, rounded to one decimal. */
|
|
204
|
+
export function feetInchesToCm(feet: number, inches: number): number {
|
|
205
|
+
return round((feet * 12 + inches) * CM_PER_INCH, 1);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Centimeters to whole feet and inches; 11.6 inches rolls into the next foot. */
|
|
209
|
+
export function cmToFeetInches(cm: number): { feet: number; inches: number } {
|
|
210
|
+
const totalInches = cm / CM_PER_INCH;
|
|
211
|
+
const feet = Math.floor(totalInches / 12);
|
|
212
|
+
const inches = Math.round(totalInches % 12);
|
|
213
|
+
if (inches === 12) return { feet: feet + 1, inches: 0 };
|
|
214
|
+
return { feet, inches };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** A canonical value plus its unit, e.g. "186.2 lb". Empty string when null. */
|
|
218
|
+
export function formatMeasure(
|
|
219
|
+
canonical: number | null | undefined,
|
|
220
|
+
scale: ScaleId,
|
|
221
|
+
system: UnitSystem,
|
|
222
|
+
): string {
|
|
223
|
+
const value = toDisplay(canonical, scale, system);
|
|
224
|
+
if (value === "") return "";
|
|
225
|
+
const { label } = unitFor(scale, system);
|
|
226
|
+
const shown = Number(value).toLocaleString();
|
|
227
|
+
return label === "" ? shown : `${shown} ${label}`;
|
|
228
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utilities for unwrapping API response envelopes.
|
|
3
|
+
* The Rails API wraps collections in { data: { collection: T[] }, meta }
|
|
4
|
+
* and entities in { data: { entity: T }, meta }.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* API response envelope for collection (index) endpoints.
|
|
9
|
+
*/
|
|
10
|
+
export interface ApiCollectionEnvelope<T> {
|
|
11
|
+
data: { collection: T[] };
|
|
12
|
+
meta: {
|
|
13
|
+
currentPage: number;
|
|
14
|
+
totalPages: number;
|
|
15
|
+
totalCount: number;
|
|
16
|
+
perPage: number;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* API response envelope for entity (show/create/update) endpoints.
|
|
22
|
+
*/
|
|
23
|
+
export interface ApiEntityEnvelope<T> {
|
|
24
|
+
data: { entity: T };
|
|
25
|
+
meta: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Extract a collection from the API envelope.
|
|
30
|
+
* Generic over both item type T and meta type M so envelopes
|
|
31
|
+
* with extended meta fields (e.g., inSeason) work without manual unwrapping.
|
|
32
|
+
*/
|
|
33
|
+
export function unwrapCollection<T, M>(envelope: {
|
|
34
|
+
data: { collection: T[] };
|
|
35
|
+
meta: M;
|
|
36
|
+
}): { data: T[]; meta: M } {
|
|
37
|
+
return {
|
|
38
|
+
data: envelope.data.collection,
|
|
39
|
+
meta: envelope.meta,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Extract a single entity from the API envelope.
|
|
45
|
+
*/
|
|
46
|
+
export function unwrapEntity<T>(envelope: ApiEntityEnvelope<T>): T {
|
|
47
|
+
return envelope.data.entity;
|
|
48
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { create } from "zustand";
|
|
2
|
+
import { persist } from "zustand/middleware";
|
|
3
|
+
import type { RegisteredUser } from "../types";
|
|
4
|
+
import { appStorage } from "./storage";
|
|
5
|
+
|
|
6
|
+
export interface AuthState {
|
|
7
|
+
token: string | null;
|
|
8
|
+
refreshToken: string | null;
|
|
9
|
+
expiresAt: number | null;
|
|
10
|
+
user: RegisteredUser | null;
|
|
11
|
+
isAuthenticated: boolean;
|
|
12
|
+
login: (token: string, refreshToken: string, expiresIn: number, user: RegisteredUser) => void;
|
|
13
|
+
setTokens: (token: string, refreshToken: string, expiresIn: number) => void;
|
|
14
|
+
logout: () => void;
|
|
15
|
+
updateUser: (user: RegisteredUser) => void;
|
|
16
|
+
isTokenExpiringSoon: (bufferMs?: number) => boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Default buffer time before token expiry to trigger proactive refresh (5 minutes).
|
|
21
|
+
*/
|
|
22
|
+
export const DEFAULT_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Authentication store with localStorage persistence.
|
|
26
|
+
* Manages JWT token, refresh token, and authenticated user state.
|
|
27
|
+
*
|
|
28
|
+
* Persisted as `<storagePrefix>-auth`. The prefix is the app's, so the store
|
|
29
|
+
* hydrates when the app configures the shell (`AppProvider` or
|
|
30
|
+
* `configureApp`), not when this module loads.
|
|
31
|
+
*/
|
|
32
|
+
export const useAuthStore = create<AuthState>()(
|
|
33
|
+
persist(
|
|
34
|
+
(set, get) => ({
|
|
35
|
+
token: null,
|
|
36
|
+
refreshToken: null,
|
|
37
|
+
expiresAt: null,
|
|
38
|
+
user: null,
|
|
39
|
+
isAuthenticated: false,
|
|
40
|
+
|
|
41
|
+
login: (token, refreshToken, expiresIn, user) =>
|
|
42
|
+
set({
|
|
43
|
+
token,
|
|
44
|
+
refreshToken,
|
|
45
|
+
expiresAt: Date.now() + expiresIn * 1000,
|
|
46
|
+
user,
|
|
47
|
+
isAuthenticated: true,
|
|
48
|
+
}),
|
|
49
|
+
|
|
50
|
+
setTokens: (token, refreshToken, expiresIn) =>
|
|
51
|
+
set({
|
|
52
|
+
token,
|
|
53
|
+
refreshToken,
|
|
54
|
+
expiresAt: Date.now() + expiresIn * 1000,
|
|
55
|
+
}),
|
|
56
|
+
|
|
57
|
+
logout: () =>
|
|
58
|
+
set({
|
|
59
|
+
token: null,
|
|
60
|
+
refreshToken: null,
|
|
61
|
+
expiresAt: null,
|
|
62
|
+
user: null,
|
|
63
|
+
isAuthenticated: false,
|
|
64
|
+
}),
|
|
65
|
+
|
|
66
|
+
updateUser: (user) => set({ user }),
|
|
67
|
+
|
|
68
|
+
isTokenExpiringSoon: (bufferMs = DEFAULT_EXPIRY_BUFFER_MS) => {
|
|
69
|
+
const { expiresAt } = get();
|
|
70
|
+
if (!expiresAt) return true;
|
|
71
|
+
return Date.now() + bufferMs >= expiresAt;
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
{
|
|
75
|
+
name: "auth",
|
|
76
|
+
storage: appStorage,
|
|
77
|
+
skipHydration: true,
|
|
78
|
+
},
|
|
79
|
+
),
|
|
80
|
+
);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createJSONStorage, type StateStorage } from "zustand/middleware";
|
|
2
|
+
import { peekAppConfig } from "../config";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* localStorage under the app's prefix, read when it is used rather than when
|
|
6
|
+
* the store is created, since the stores live in the package and the prefix is
|
|
7
|
+
* the app's. Before the app is configured nothing is read or written, and a
|
|
8
|
+
* browser that refuses storage (a private window, the server) is a no op.
|
|
9
|
+
*/
|
|
10
|
+
const prefixed: StateStorage = {
|
|
11
|
+
getItem: (name) => {
|
|
12
|
+
const config = peekAppConfig();
|
|
13
|
+
if (!config) return null;
|
|
14
|
+
try {
|
|
15
|
+
return localStorage.getItem(`${config.storagePrefix}-${name}`);
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
setItem: (name, value) => {
|
|
21
|
+
const config = peekAppConfig();
|
|
22
|
+
if (!config) return;
|
|
23
|
+
try {
|
|
24
|
+
localStorage.setItem(`${config.storagePrefix}-${name}`, value);
|
|
25
|
+
} catch {
|
|
26
|
+
// Storage refused; the state lives in memory for this visit.
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
removeItem: (name) => {
|
|
30
|
+
const config = peekAppConfig();
|
|
31
|
+
if (!config) return;
|
|
32
|
+
try {
|
|
33
|
+
localStorage.removeItem(`${config.storagePrefix}-${name}`);
|
|
34
|
+
} catch {
|
|
35
|
+
// Nothing stored to remove.
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const appStorage = createJSONStorage(() => prefixed);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { create } from "zustand";
|
|
2
|
+
import { persist } from "zustand/middleware";
|
|
3
|
+
import type { MeasureId, UnitPreferences, UnitSystem } from "../lib/units";
|
|
4
|
+
import { appStorage } from "./storage";
|
|
5
|
+
|
|
6
|
+
export interface UnitsState {
|
|
7
|
+
systems: UnitPreferences;
|
|
8
|
+
setSystem: (measure: MeasureId, system: UnitSystem) => void;
|
|
9
|
+
/** Take the person's saved choices, as read from the api, over the defaults. */
|
|
10
|
+
hydrate: (systems: Partial<UnitPreferences> | null | undefined) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Defaults. Body weight and temperature read imperial to match the rest of the
|
|
15
|
+
* app (miles, feet, the profile's pounds); fuel and hydration stay metric
|
|
16
|
+
* because bottles, bladders, and gel packets are labelled in ml, L, and g.
|
|
17
|
+
* Every one of them is one dropdown away from the other system.
|
|
18
|
+
*/
|
|
19
|
+
export const DEFAULT_SYSTEMS: UnitPreferences = {
|
|
20
|
+
bodyMass: "imperial",
|
|
21
|
+
volume: "metric",
|
|
22
|
+
foodMass: "metric",
|
|
23
|
+
temperature: "imperial",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Which system each measure displays in, persisted to localStorage so the
|
|
28
|
+
* choice survives a reload, and kept in step with the person's account by
|
|
29
|
+
* UserContext: their saved choices hydrate the store on sign in, and a change
|
|
30
|
+
* here is written back. Preference only: the API is always sent canonical
|
|
31
|
+
* units. Persisted as `<storagePrefix>-units`, hydrated when the app
|
|
32
|
+
* configures the shell.
|
|
33
|
+
*/
|
|
34
|
+
export const useUnitsStore = create<UnitsState>()(
|
|
35
|
+
persist(
|
|
36
|
+
(set) => ({
|
|
37
|
+
systems: DEFAULT_SYSTEMS,
|
|
38
|
+
setSystem: (measure, system) =>
|
|
39
|
+
set((state) => ({ systems: { ...state.systems, [measure]: system } })),
|
|
40
|
+
hydrate: (systems) => set({ systems: { ...DEFAULT_SYSTEMS, ...(systems ?? {}) } }),
|
|
41
|
+
}),
|
|
42
|
+
{
|
|
43
|
+
name: "units",
|
|
44
|
+
storage: appStorage,
|
|
45
|
+
skipHydration: true,
|
|
46
|
+
// Merge stored values over the defaults so a measure added later
|
|
47
|
+
// starts on its default instead of arriving undefined.
|
|
48
|
+
merge: (persisted, current) => {
|
|
49
|
+
const stored = (persisted as Partial<UnitsState> | undefined)?.systems;
|
|
50
|
+
return { ...current, systems: { ...DEFAULT_SYSTEMS, ...stored } };
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
),
|
|
54
|
+
);
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire protocol every app on this shell shares with its api (pagination
|
|
3
|
+
* and the token pair), and the signed in user as the shell reads it. Domain
|
|
4
|
+
* types are the app's.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { UnitPreferences } from "../lib/units";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The signed in person, as much of them as the shell reads: the name, the
|
|
11
|
+
* avatar, the username (the segment their public pages live under), and the
|
|
12
|
+
* unit preferences it keeps in step with the units store. An app's own user
|
|
13
|
+
* type extends this and registers itself (see `Register`).
|
|
14
|
+
*/
|
|
15
|
+
export interface AppUser {
|
|
16
|
+
id: string;
|
|
17
|
+
email: string;
|
|
18
|
+
firstName: string;
|
|
19
|
+
lastName: string;
|
|
20
|
+
username: string | null;
|
|
21
|
+
avatar: string | null;
|
|
22
|
+
units?: UnitPreferences;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Where an app names its own user type, once, so every hook and store the
|
|
27
|
+
* shell exports hands it back typed in full:
|
|
28
|
+
*
|
|
29
|
+
* ```ts
|
|
30
|
+
* declare module "@atelic-action/ui/app" {
|
|
31
|
+
* interface Register {
|
|
32
|
+
* user: User;
|
|
33
|
+
* }
|
|
34
|
+
* }
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
// biome-ignore lint/suspicious/noEmptyInterface: the app augments it, as TanStack's Register is augmented
|
|
38
|
+
export interface Register {}
|
|
39
|
+
|
|
40
|
+
/** The app's registered user type, or `AppUser` when none is registered. */
|
|
41
|
+
export type RegisteredUser = Register extends { user: infer U extends AppUser } ? U : AppUser;
|
|
42
|
+
|
|
43
|
+
export interface PaginatedResponse<T> {
|
|
44
|
+
data: T[];
|
|
45
|
+
meta: {
|
|
46
|
+
currentPage: number;
|
|
47
|
+
totalPages: number;
|
|
48
|
+
totalCount: number;
|
|
49
|
+
perPage: number;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface PaginationLinks {
|
|
54
|
+
self: string;
|
|
55
|
+
first: string;
|
|
56
|
+
prev: string | null;
|
|
57
|
+
next: string | null;
|
|
58
|
+
last: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface TokenResponse {
|
|
62
|
+
token: string;
|
|
63
|
+
refreshToken: string;
|
|
64
|
+
expiresIn: number;
|
|
65
|
+
user: RegisteredUser;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface RefreshResponse {
|
|
69
|
+
token: string;
|
|
70
|
+
refreshToken: string;
|
|
71
|
+
expiresIn: number;
|
|
72
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface ToastMeta {
|
|
2
|
+
success?: string | ((data: unknown) => string);
|
|
3
|
+
successDescription?: string | ((data: unknown) => string);
|
|
4
|
+
error?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
declare module "@tanstack/react-query" {
|
|
8
|
+
interface Register {
|
|
9
|
+
mutationMeta: { toast?: ToastMeta };
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
|
4
|
+
import { ChevronDown } from "lucide-react";
|
|
5
|
+
import * as React from "react";
|
|
6
|
+
|
|
7
|
+
import { cn } from "../lib/utils";
|
|
8
|
+
|
|
9
|
+
const Accordion = AccordionPrimitive.Root;
|
|
10
|
+
|
|
11
|
+
const AccordionItem = React.forwardRef<
|
|
12
|
+
React.ElementRef<typeof AccordionPrimitive.Item>,
|
|
13
|
+
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
|
14
|
+
>(({ className, ...props }, ref) => (
|
|
15
|
+
<AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props} />
|
|
16
|
+
));
|
|
17
|
+
AccordionItem.displayName = "AccordionItem";
|
|
18
|
+
|
|
19
|
+
const AccordionTrigger = React.forwardRef<
|
|
20
|
+
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
|
21
|
+
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
|
22
|
+
>(({ className, children, ...props }, ref) => (
|
|
23
|
+
<AccordionPrimitive.Header className="flex">
|
|
24
|
+
<AccordionPrimitive.Trigger
|
|
25
|
+
ref={ref}
|
|
26
|
+
className={cn(
|
|
27
|
+
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
|
28
|
+
className,
|
|
29
|
+
)}
|
|
30
|
+
{...props}
|
|
31
|
+
>
|
|
32
|
+
{children}
|
|
33
|
+
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
|
34
|
+
</AccordionPrimitive.Trigger>
|
|
35
|
+
</AccordionPrimitive.Header>
|
|
36
|
+
));
|
|
37
|
+
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
|
38
|
+
|
|
39
|
+
const AccordionContent = React.forwardRef<
|
|
40
|
+
React.ElementRef<typeof AccordionPrimitive.Content>,
|
|
41
|
+
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
|
42
|
+
>(({ className, children, ...props }, ref) => (
|
|
43
|
+
<AccordionPrimitive.Content
|
|
44
|
+
ref={ref}
|
|
45
|
+
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
|
46
|
+
{...props}
|
|
47
|
+
>
|
|
48
|
+
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
|
49
|
+
</AccordionPrimitive.Content>
|
|
50
|
+
));
|
|
51
|
+
|
|
52
|
+
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
|
53
|
+
|
|
54
|
+
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
|