@atelic-action/ui 0.17.2 → 1.1.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.
Files changed (188) hide show
  1. package/README.md +432 -45
  2. package/bin/atelic-check-base-path.ts +5 -0
  3. package/bin/atelic-clean-sitemap.ts +5 -0
  4. package/bin/atelic-contact-canary.ts +5 -0
  5. package/bin/atelic-print-sheets.sh +1 -1
  6. package/bin/atelic-seal-document.mjs +1 -1
  7. package/bin/atelic-serve-static.ts +1 -1
  8. package/dist/gate/index.d.ts +1 -1
  9. package/dist/gate/index.js +1 -1
  10. package/dist/gate/middleware.d.ts +4 -1
  11. package/dist/gate/middleware.js +14 -5
  12. package/dist/gate/wall.d.ts +25 -0
  13. package/dist/gate/wall.js +41 -2
  14. package/dist/routing/basePath.d.ts +24 -0
  15. package/dist/routing/basePath.js +54 -0
  16. package/dist/vite/index.d.ts +3 -2
  17. package/dist/vite/index.js +3 -2
  18. package/dist/vite/marketing-config.d.ts +50 -0
  19. package/dist/vite/marketing-config.js +83 -0
  20. package/package.json +164 -5
  21. package/src/app/AppProvider.tsx +41 -0
  22. package/src/app/AuthGuard.tsx +27 -0
  23. package/src/app/CardErrorBoundary.tsx +39 -0
  24. package/src/app/ErrorFallback.tsx +39 -0
  25. package/src/app/GoogleGlyph.tsx +22 -0
  26. package/src/app/config.ts +54 -0
  27. package/src/app/configure.ts +18 -0
  28. package/src/app/context/AppContext.tsx +52 -0
  29. package/src/app/context/SidebarContext.tsx +124 -0
  30. package/src/app/context/UserContext.tsx +139 -0
  31. package/src/app/hooks/useDebounce.ts +12 -0
  32. package/src/app/hooks/useIsDesktop.ts +31 -0
  33. package/src/app/hooks/useWakeLock.ts +49 -0
  34. package/src/app/index.ts +64 -0
  35. package/src/app/layout/ActionButton.tsx +67 -0
  36. package/src/app/layout/AppLayout.tsx +110 -0
  37. package/src/app/layout/AppPage.tsx +268 -0
  38. package/src/app/layout/Breadcrumbs.tsx +57 -0
  39. package/src/app/layout/DesktopRail.tsx +168 -0
  40. package/src/app/layout/FormActionButtons.tsx +69 -0
  41. package/src/app/layout/MobileMenu.tsx +156 -0
  42. package/src/app/layout/MobileTabBar.tsx +43 -0
  43. package/src/app/layout/PageError.tsx +30 -0
  44. package/src/app/layout/PageSkeleton.tsx +65 -0
  45. package/src/app/layout/SectionCards.tsx +33 -0
  46. package/src/app/layout/UserMenu.tsx +104 -0
  47. package/src/app/layout/identity.ts +22 -0
  48. package/src/app/layout/index.ts +20 -0
  49. package/src/app/layout/nav.ts +52 -0
  50. package/src/app/lib/dates.ts +57 -0
  51. package/src/app/lib/env.ts +37 -0
  52. package/src/app/lib/format.ts +61 -0
  53. package/src/app/lib/http.ts +205 -0
  54. package/src/app/lib/lastAuth.ts +45 -0
  55. package/src/app/lib/oauth.ts +32 -0
  56. package/src/app/lib/publicRoutes.ts +33 -0
  57. package/src/app/lib/queryClient.ts +38 -0
  58. package/src/app/lib/search.ts +17 -0
  59. package/src/app/lib/session.ts +36 -0
  60. package/src/app/lib/tokenRefresh.ts +137 -0
  61. package/src/app/lib/transformKeys.ts +60 -0
  62. package/src/app/lib/units.ts +228 -0
  63. package/src/app/lib/unwrap.ts +48 -0
  64. package/src/app/lib/utils.ts +10 -0
  65. package/src/app/stores/authStore.ts +80 -0
  66. package/src/app/stores/storage.ts +40 -0
  67. package/src/app/stores/unitsStore.ts +54 -0
  68. package/src/app/types/index.ts +72 -0
  69. package/src/app/types/query.ts +11 -0
  70. package/src/app/ui/accordion.tsx +54 -0
  71. package/src/app/ui/alert-dialog.tsx +114 -0
  72. package/src/app/ui/alert.tsx +49 -0
  73. package/src/app/ui/avatar.tsx +45 -0
  74. package/src/app/ui/badge-list.tsx +51 -0
  75. package/src/app/ui/badge.tsx +33 -0
  76. package/src/app/ui/button.tsx +49 -0
  77. package/src/app/ui/callout.tsx +22 -0
  78. package/src/app/ui/card.tsx +55 -0
  79. package/src/app/ui/checkbox.tsx +26 -0
  80. package/src/app/ui/clear-button.tsx +42 -0
  81. package/src/app/ui/command.tsx +140 -0
  82. package/src/app/ui/country-dropdown.tsx +184 -0
  83. package/src/app/ui/dialog.tsx +104 -0
  84. package/src/app/ui/dropdown-menu.tsx +185 -0
  85. package/src/app/ui/form.tsx +172 -0
  86. package/src/app/ui/index.ts +36 -0
  87. package/src/app/ui/input.tsx +22 -0
  88. package/src/app/ui/label.tsx +19 -0
  89. package/src/app/ui/option-select.tsx +56 -0
  90. package/src/app/ui/pagination.tsx +90 -0
  91. package/src/app/ui/popover.tsx +29 -0
  92. package/src/app/ui/progress.tsx +25 -0
  93. package/src/app/ui/search-input.tsx +63 -0
  94. package/src/app/ui/select.tsx +151 -0
  95. package/src/app/ui/separator.tsx +24 -0
  96. package/src/app/ui/sheet.tsx +119 -0
  97. package/src/app/ui/slider.tsx +23 -0
  98. package/src/app/ui/switch.tsx +27 -0
  99. package/src/app/ui/textarea.tsx +21 -0
  100. package/src/app/ui/tooltip.tsx +32 -0
  101. package/src/artifact/ArtifactShell.tsx +2 -2
  102. package/src/artifact/Gate.tsx +2 -2
  103. package/src/artifact/Note.tsx +9 -39
  104. package/src/artifact/Paragraph.tsx +65 -0
  105. package/src/artifact/Read.tsx +406 -0
  106. package/src/artifact/Writeup.tsx +7 -7
  107. package/src/artifact/index.ts +1 -0
  108. package/src/chrome/SiteShell.tsx +141 -0
  109. package/src/chrome/index.ts +2 -0
  110. package/src/components/Disclosure.tsx +80 -14
  111. package/src/components/GateLogin.tsx +1 -1
  112. package/src/components/Watermark.tsx +1 -1
  113. package/src/components/index.ts +6 -2
  114. package/src/gate/index.ts +1 -1
  115. package/src/gate/middleware.ts +17 -6
  116. package/src/gate/wall.ts +60 -2
  117. package/src/lib/basePathAudit.ts +96 -0
  118. package/src/lib/displayDate.ts +25 -0
  119. package/src/lib/honeypot.ts +11 -0
  120. package/src/lib/hours.ts +28 -0
  121. package/src/lib/sitemap.ts +34 -0
  122. package/src/lib/trackEvent.ts +26 -0
  123. package/src/lib/useOpenHeight.ts +28 -0
  124. package/src/primitives/Button.tsx +3 -1
  125. package/src/primitives/Chip.tsx +3 -1
  126. package/src/primitives/ImageFrame.tsx +48 -0
  127. package/src/primitives/ProseBlocks.tsx +67 -0
  128. package/src/primitives/QuoteCard.tsx +20 -0
  129. package/src/primitives/StarRow.tsx +37 -0
  130. package/src/primitives/TickList.tsx +24 -0
  131. package/src/primitives/index.ts +5 -0
  132. package/src/routing/BasePathProvider.tsx +47 -0
  133. package/src/routing/basePath.ts +57 -0
  134. package/src/routing/index.ts +8 -0
  135. package/src/scripts/check-base-path.ts +130 -0
  136. package/src/scripts/clean-sitemap.ts +27 -0
  137. package/src/scripts/contact-canary.ts +98 -0
  138. package/src/scripts/serve-static.ts +29 -3
  139. package/src/sections/BlogIndex.tsx +57 -0
  140. package/src/sections/BlogPost.tsx +65 -0
  141. package/src/sections/CTABanner.tsx +93 -0
  142. package/src/sections/CTASection.tsx +60 -0
  143. package/src/sections/ComparisonTable.tsx +114 -0
  144. package/src/sections/ContactForm.tsx +163 -0
  145. package/src/sections/ContactInfoCard.tsx +92 -0
  146. package/src/sections/CredentialList.tsx +28 -0
  147. package/src/sections/DeviceShowcase.tsx +39 -0
  148. package/src/sections/ExpandList.tsx +58 -0
  149. package/src/sections/FAQBrowser.tsx +283 -0
  150. package/src/sections/FeatureDetail.tsx +60 -0
  151. package/src/sections/FeatureGrid.tsx +83 -0
  152. package/src/sections/Gallery.tsx +84 -0
  153. package/src/sections/Hero.tsx +110 -0
  154. package/src/sections/HoursTable.tsx +23 -0
  155. package/src/sections/JumpRail.tsx +35 -0
  156. package/src/sections/LegalPage.tsx +43 -0
  157. package/src/sections/LocationSection.tsx +66 -0
  158. package/src/sections/Locations.tsx +71 -0
  159. package/src/sections/LogoMarquee.tsx +27 -0
  160. package/src/sections/MapEmbed.tsx +75 -0
  161. package/src/sections/MediaBand.tsx +20 -0
  162. package/src/sections/MenuList.tsx +44 -0
  163. package/src/sections/PageHero.tsx +25 -0
  164. package/src/sections/PressQuotes.tsx +51 -0
  165. package/src/sections/PricingBand.tsx +43 -0
  166. package/src/sections/PricingCard.tsx +36 -0
  167. package/src/sections/ProcessSteps.tsx +45 -0
  168. package/src/sections/ProfileCards.tsx +73 -0
  169. package/src/sections/RevealSlider.tsx +156 -0
  170. package/src/sections/ReviewCarousel.tsx +223 -0
  171. package/src/sections/ReviewWall.tsx +220 -0
  172. package/src/sections/SocialStrip.tsx +83 -0
  173. package/src/sections/SplitSection.tsx +56 -0
  174. package/src/sections/index.ts +76 -0
  175. package/src/sections/siteConfig.ts +201 -0
  176. package/src/styles/app.css +673 -0
  177. package/src/styles/artifact.css +759 -401
  178. package/src/styles/base.css +49 -47
  179. package/src/styles/chrome.css +71 -71
  180. package/src/styles/components.css +150 -140
  181. package/src/styles/gate.css +29 -29
  182. package/src/styles/primitives.css +190 -25
  183. package/src/styles/sections.css +2652 -0
  184. package/src/styles/star-rating.css +2 -2
  185. package/src/vite/index.ts +9 -2
  186. package/src/vite/marketing-config.ts +128 -0
  187. package/src/components/Collapsible.tsx +0 -43
  188. /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,10 @@
1
+ import { type ClassValue, clsx } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ /**
5
+ * Merge Tailwind classes with clsx.
6
+ * Used by shadcn/ui components.
7
+ */
8
+ export function cn(...inputs: ClassValue[]) {
9
+ return twMerge(clsx(inputs));
10
+ }
@@ -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 };