@stacksjs/defaults 0.70.54

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 (286) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +2 -0
  3. package/package.json +20 -0
  4. package/resources/assets/fonts/013717e9.woff +0 -0
  5. package/resources/assets/fonts/03355817.woff2 +0 -0
  6. package/resources/assets/fonts/0380de7e.woff2 +0 -0
  7. package/resources/assets/fonts/0ba5d32a.woff +0 -0
  8. package/resources/assets/fonts/0bd57ddf.woff2 +0 -0
  9. package/resources/assets/fonts/19de0a60.woff2 +0 -0
  10. package/resources/assets/fonts/20b87e84.woff +0 -0
  11. package/resources/assets/fonts/4bbc67e1.woff +0 -0
  12. package/resources/assets/fonts/576fb0d7.woff +0 -0
  13. package/resources/assets/fonts/5ea10ee5.woff2 +0 -0
  14. package/resources/assets/fonts/6413fc0a.woff +0 -0
  15. package/resources/assets/fonts/72c114cf.woff +0 -0
  16. package/resources/assets/fonts/73c57e73.woff2 +0 -0
  17. package/resources/assets/fonts/813dca6c.woff +0 -0
  18. package/resources/assets/fonts/8a9b0995.woff2 +0 -0
  19. package/resources/assets/fonts/90b4832f.woff +0 -0
  20. package/resources/assets/fonts/9394ab85.woff2 +0 -0
  21. package/resources/assets/fonts/9b3c7d7f.woff2 +0 -0
  22. package/resources/assets/fonts/Monaco.ttf +0 -0
  23. package/resources/assets/fonts/b1b36d74.woff +0 -0
  24. package/resources/assets/fonts/b5d74798.woff2 +0 -0
  25. package/resources/assets/fonts/b9f81a4d.woff +0 -0
  26. package/resources/assets/fonts/c9784008.woff2 +0 -0
  27. package/resources/assets/fonts/cf692227.woff +0 -0
  28. package/resources/assets/fonts/d1c6ab51.woff2 +0 -0
  29. package/resources/assets/fonts/e2eb62fc.woff +0 -0
  30. package/resources/assets/fonts/fe21f8d8.woff2 +0 -0
  31. package/resources/assets/fonts/nps/CampmateScript-Regular.woff2 +0 -0
  32. package/resources/assets/fonts/nps/NPS_2026-variable.woff2 +0 -0
  33. package/resources/assets/fonts/nps/RedwoodSerif-Regular.woff2 +0 -0
  34. package/resources/assets/fonts/nps/SequoiaSans-Regular.woff2 +0 -0
  35. package/resources/assets/fonts/nps/Switchback-Regular.woff2 +0 -0
  36. package/resources/assets/images/app-icon.png +0 -0
  37. package/resources/assets/images/bg-img.png +0 -0
  38. package/resources/assets/images/park-ridge.svg +15 -0
  39. package/resources/assets/images/topography.svg +1 -0
  40. package/resources/assets/scripts/main.ts +2153 -0
  41. package/resources/assets/styles/README.md +19 -0
  42. package/resources/assets/styles/docs/main.css +1 -0
  43. package/resources/assets/styles/docs/overrides.css +1 -0
  44. package/resources/assets/styles/docs/vars.css +8 -0
  45. package/resources/assets/styles/main.css +1419 -0
  46. package/resources/components/Button.stx +70 -0
  47. package/resources/components/Buttons/BaseButton.stx +39 -0
  48. package/resources/components/Buttons/Counter.stx +28 -0
  49. package/resources/components/Buttons/ToggleDark.stx +11 -0
  50. package/resources/components/Container.stx +10 -0
  51. package/resources/components/Dashboard/.gitkeep +0 -0
  52. package/resources/components/Dashboard/Auth/ForgotPassword.stx +127 -0
  53. package/resources/components/Dashboard/Auth/Login.stx +212 -0
  54. package/resources/components/Dashboard/Auth/Register.stx +306 -0
  55. package/resources/components/Dashboard/Avatar.stx +123 -0
  56. package/resources/components/Dashboard/Billing/ActivePlan.stx +57 -0
  57. package/resources/components/Dashboard/Billing/CardBrands.stx +32 -0
  58. package/resources/components/Dashboard/Billing/CardForm.stx +34 -0
  59. package/resources/components/Dashboard/Billing/OneTimePayment.stx +28 -0
  60. package/resources/components/Dashboard/Billing/PaymentForm.stx +33 -0
  61. package/resources/components/Dashboard/Billing/PaymentMethod.stx +66 -0
  62. package/resources/components/Dashboard/Billing/PaymentMethodList.stx +52 -0
  63. package/resources/components/Dashboard/Billing/Plans.stx +136 -0
  64. package/resources/components/Dashboard/Buttons/AppButton.stx +48 -0
  65. package/resources/components/Dashboard/Buttons/IconButton.stx +20 -0
  66. package/resources/components/Dashboard/Card.stx +78 -0
  67. package/resources/components/Dashboard/Chart.stx +75 -0
  68. package/resources/components/Dashboard/CodeEditor.stx +41 -0
  69. package/resources/components/Dashboard/Commerce/BulkActionBar.stx +70 -0
  70. package/resources/components/Dashboard/Commerce/CategoriesTable.stx +133 -0
  71. package/resources/components/Dashboard/Commerce/CouponsTable.stx +161 -0
  72. package/resources/components/Dashboard/Commerce/CustomersTable.stx +142 -0
  73. package/resources/components/Dashboard/Commerce/Delivery/DeliveryRoutesTable.stx +80 -0
  74. package/resources/components/Dashboard/Commerce/Delivery/DigitalDeliveryTable.stx +85 -0
  75. package/resources/components/Dashboard/Commerce/Delivery/DriversTable.stx +99 -0
  76. package/resources/components/Dashboard/Commerce/Delivery/LicenseKeysTable.stx +92 -0
  77. package/resources/components/Dashboard/Commerce/Delivery/LicenseTemplatesTable.stx +88 -0
  78. package/resources/components/Dashboard/Commerce/Delivery/Pagination.stx +122 -0
  79. package/resources/components/Dashboard/Commerce/Delivery/SearchFilter.stx +23 -0
  80. package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodsTable.stx +80 -0
  81. package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesTable.stx +59 -0
  82. package/resources/components/Dashboard/Commerce/Delivery/ShippingZonesTable.stx +71 -0
  83. package/resources/components/Dashboard/Commerce/Delivery/TabNavigation.stx +36 -0
  84. package/resources/components/Dashboard/Commerce/Forms/CouponsForm.stx +191 -0
  85. package/resources/components/Dashboard/Commerce/OrdersTable.stx +131 -0
  86. package/resources/components/Dashboard/Commerce/ProductsTable.stx +170 -0
  87. package/resources/components/Dashboard/CreateRecordModal.stx +363 -0
  88. package/resources/components/Dashboard/DashboardLayout.stx +155 -0
  89. package/resources/components/Dashboard/Deployments/ActivityFeed.stx +54 -0
  90. package/resources/components/Dashboard/Deployments/DeployScript.stx +23 -0
  91. package/resources/components/Dashboard/Deployments/DeploymentHistory.stx +97 -0
  92. package/resources/components/Dashboard/Deployments/DeploymentList.stx +94 -0
  93. package/resources/components/Dashboard/Deployments/LiveTerminalOutput.stx +18 -0
  94. package/resources/components/Dashboard/Elements/Dropdown.stx +57 -0
  95. package/resources/components/Dashboard/Email/EmailActivityChart.stx +111 -0
  96. package/resources/components/Dashboard/Email/EmailCompose.stx +72 -0
  97. package/resources/components/Dashboard/Email/EmailDetail.stx +105 -0
  98. package/resources/components/Dashboard/Email/EmailList.stx +117 -0
  99. package/resources/components/Dashboard/Email/EmailSearch.stx +40 -0
  100. package/resources/components/Dashboard/Email/EmailSidebar.stx +133 -0
  101. package/resources/components/Dashboard/GlobalSearch.stx +124 -0
  102. package/resources/components/Dashboard/MobileSidebar.stx +47 -0
  103. package/resources/components/Dashboard/Modals/BaseModal.stx +50 -0
  104. package/resources/components/Dashboard/Modals/Popups/Alert.stx +112 -0
  105. package/resources/components/Dashboard/Modals/Popups/Toast.stx +70 -0
  106. package/resources/components/Dashboard/Modals/ToastWrapper.stx +35 -0
  107. package/resources/components/Dashboard/Monitoring/ErrorDetailModal.stx +228 -0
  108. package/resources/components/Dashboard/Monitoring/ErrorsTable.stx +184 -0
  109. package/resources/components/Dashboard/Navbar.stx +194 -0
  110. package/resources/components/Dashboard/NavbarModern.stx +188 -0
  111. package/resources/components/Dashboard/NotificationErrorModal.stx +105 -0
  112. package/resources/components/Dashboard/NotificationStatusBadge.stx +20 -0
  113. package/resources/components/Dashboard/Pagination.stx +90 -0
  114. package/resources/components/Dashboard/Queue/QueueTable.stx +127 -0
  115. package/resources/components/Dashboard/Settings/Forms/AIForm.stx +126 -0
  116. package/resources/components/Dashboard/Settings/Forms/AnalyticsForm.stx +52 -0
  117. package/resources/components/Dashboard/Settings/Forms/AppForm.stx +157 -0
  118. package/resources/components/Dashboard/Settings/Forms/CLIForm.stx +66 -0
  119. package/resources/components/Dashboard/Settings/Forms/CacheForm.stx +253 -0
  120. package/resources/components/Dashboard/Settings/Forms/CloudForm.stx +441 -0
  121. package/resources/components/Dashboard/Settings/Forms/DNSForm.stx +307 -0
  122. package/resources/components/Dashboard/Settings/Forms/DatabaseForm.stx +168 -0
  123. package/resources/components/Dashboard/Settings/Forms/EmailForm.stx +100 -0
  124. package/resources/components/Dashboard/Settings/Forms/FileSystemForm.stx +259 -0
  125. package/resources/components/Dashboard/Settings/Forms/HashingForm.stx +119 -0
  126. package/resources/components/Dashboard/Settings/Forms/LibraryForm.stx +389 -0
  127. package/resources/components/Dashboard/Settings/Forms/QueueForm.stx +219 -0
  128. package/resources/components/Dashboard/Settings/Forms/SearchEngineForm.stx +48 -0
  129. package/resources/components/Dashboard/Settings/Forms/SecurityForm.stx +143 -0
  130. package/resources/components/Dashboard/Settings/Forms/ServicesForm.stx +194 -0
  131. package/resources/components/Dashboard/Settings/Forms/StorageForm.stx +191 -0
  132. package/resources/components/Dashboard/Settings/Forms/TeamsForm.stx +33 -0
  133. package/resources/components/Dashboard/Settings/SettingsFormManager.stx +181 -0
  134. package/resources/components/Dashboard/SettingsHeader.stx +88 -0
  135. package/resources/components/Dashboard/SettingsSidebar.stx +234 -0
  136. package/resources/components/Dashboard/Sidebar.stx +23 -0
  137. package/resources/components/Dashboard/SidebarModern.stx +249 -0
  138. package/resources/components/Dashboard/Skeleton/LoadingCard.stx +15 -0
  139. package/resources/components/Dashboard/Skeleton/LoadingDetails.stx +26 -0
  140. package/resources/components/Dashboard/Table.stx +82 -0
  141. package/resources/components/Dashboard/TerminalContainer.stx +97 -0
  142. package/resources/components/Dashboard/Tooltip.stx +166 -0
  143. package/resources/components/Dashboard/Transaction/index.stx +88 -0
  144. package/resources/components/Dashboard/UI/ActivityTable.stx +54 -0
  145. package/resources/components/Dashboard/UI/Avatar.stx +102 -0
  146. package/resources/components/Dashboard/UI/Badge.stx +66 -0
  147. package/resources/components/Dashboard/UI/Button.stx +73 -0
  148. package/resources/components/Dashboard/UI/Card.stx +51 -0
  149. package/resources/components/Dashboard/UI/ChartCard.stx +36 -0
  150. package/resources/components/Dashboard/UI/Checkbox.stx +74 -0
  151. package/resources/components/Dashboard/UI/ConfirmDialog.stx +74 -0
  152. package/resources/components/Dashboard/UI/DataTable.stx +45 -0
  153. package/resources/components/Dashboard/UI/Dropdown.stx +72 -0
  154. package/resources/components/Dashboard/UI/DropdownItem.stx +80 -0
  155. package/resources/components/Dashboard/UI/EmptyState.stx +50 -0
  156. package/resources/components/Dashboard/UI/FilterBar.stx +48 -0
  157. package/resources/components/Dashboard/UI/Input.stx +77 -0
  158. package/resources/components/Dashboard/UI/Modal.stx +75 -0
  159. package/resources/components/Dashboard/UI/PageHeader.stx +69 -0
  160. package/resources/components/Dashboard/UI/PageLayout.stx +84 -0
  161. package/resources/components/Dashboard/UI/Pagination.stx +126 -0
  162. package/resources/components/Dashboard/UI/QuickLinks.stx +31 -0
  163. package/resources/components/Dashboard/UI/RouterLink.stx +31 -0
  164. package/resources/components/Dashboard/UI/Select.stx +84 -0
  165. package/resources/components/Dashboard/UI/ServiceHealth.stx +38 -0
  166. package/resources/components/Dashboard/UI/StatsCard.stx +79 -0
  167. package/resources/components/Dashboard/UI/Table.stx +143 -0
  168. package/resources/components/Dashboard/UI/Tabs.stx +45 -0
  169. package/resources/components/Dashboard/UI/Textarea.stx +93 -0
  170. package/resources/components/Dashboard/UI/Toggle.stx +84 -0
  171. package/resources/components/Dashboard/UI/WindowControls.stx +135 -0
  172. package/resources/components/Dashboard/Widget.stx +89 -0
  173. package/resources/components/Dashboard/WindowControls.stx +119 -0
  174. package/resources/components/Dashboard/index.ts +106 -0
  175. package/resources/components/Docs/Demo/ComboboxCode.md +114 -0
  176. package/resources/components/Docs/Demo/ComboboxDemo.stx +91 -0
  177. package/resources/components/Docs/Demo/DialogCode.md +42 -0
  178. package/resources/components/Docs/Demo/DialogDemo.stx +76 -0
  179. package/resources/components/Docs/Demo/DocsPlayground.stx +39 -0
  180. package/resources/components/Docs/Demo/DropdownCode.md +109 -0
  181. package/resources/components/Docs/Demo/DropdownDemo.stx +82 -0
  182. package/resources/components/Docs/Demo/Hero.stx +100 -0
  183. package/resources/components/Docs/Demo/ListboxCode.md +87 -0
  184. package/resources/components/Docs/Demo/ListboxDemo.stx +59 -0
  185. package/resources/components/Docs/Demo/NotificationCode.md +173 -0
  186. package/resources/components/Docs/Demo/NotificationDemo.stx +99 -0
  187. package/resources/components/Docs/Demo/PopoverCode.md +161 -0
  188. package/resources/components/Docs/Demo/PopoverDemo.stx +96 -0
  189. package/resources/components/Docs/Demo/RadioGroupCode.md +98 -0
  190. package/resources/components/Docs/Demo/RadioGroupDemo.stx +53 -0
  191. package/resources/components/Docs/Demo/StepperCode.md +216 -0
  192. package/resources/components/Docs/Demo/StepperDemo.stx +137 -0
  193. package/resources/components/Docs/Demo/SwitchCode.md +24 -0
  194. package/resources/components/Docs/Demo/SwitchDemo.stx +28 -0
  195. package/resources/components/Docs/Home.stx +7 -0
  196. package/resources/components/Docs/HomeContributors.stx +23 -0
  197. package/resources/components/Docs/HomeSponsors.stx +17 -0
  198. package/resources/components/Docs/HomeTeam.stx +18 -0
  199. package/resources/components/Docs/TeamMember.stx +79 -0
  200. package/resources/components/Email/EmailButton.stx +32 -0
  201. package/resources/components/Email/EmailDivider.stx +20 -0
  202. package/resources/components/Email/EmailImage.stx +24 -0
  203. package/resources/components/Email/EmailLayout.stx +55 -0
  204. package/resources/components/Email/EmailLink.stx +22 -0
  205. package/resources/components/Email/EmailSection.stx +20 -0
  206. package/resources/components/Email/EmailText.stx +30 -0
  207. package/resources/components/Forum/ForumBreadcrumb.stx +38 -0
  208. package/resources/components/Forum/ForumCategory.stx +16 -0
  209. package/resources/components/Forum/ForumCategoryItem.stx +46 -0
  210. package/resources/components/Forum/ForumLayout.stx +36 -0
  211. package/resources/components/Forum/ForumPagination.stx +39 -0
  212. package/resources/components/Forum/ForumPost.stx +71 -0
  213. package/resources/components/Forum/ForumReplyForm.stx +76 -0
  214. package/resources/components/Forum/ForumSidebar.stx +86 -0
  215. package/resources/components/Forum/ForumTopicItem.stx +77 -0
  216. package/resources/components/Forum/ForumTopicList.stx +23 -0
  217. package/resources/components/HelloWorld.stx +253 -0
  218. package/resources/components/Logo.stx +10 -0
  219. package/resources/components/Marketing/CallToAction.stx +30 -0
  220. package/resources/components/Marketing/ComingSoon.stx +155 -0
  221. package/resources/components/Marketing/Faqs.stx +99 -0
  222. package/resources/components/Marketing/Feature.stx +32 -0
  223. package/resources/components/Marketing/Fields.stx +28 -0
  224. package/resources/components/Marketing/Footer.stx +33 -0
  225. package/resources/components/Marketing/Header.stx +51 -0
  226. package/resources/components/Marketing/Hero.stx +46 -0
  227. package/resources/components/Marketing/Layout.stx +26 -0
  228. package/resources/components/Marketing/NavLink.stx +13 -0
  229. package/resources/components/Marketing/Plan.stx +43 -0
  230. package/resources/components/Marketing/Pricing.stx +120 -0
  231. package/resources/components/Marketing/PrimaryFeatures.stx +80 -0
  232. package/resources/components/Marketing/PrimaryFeatures2.stx +98 -0
  233. package/resources/components/Marketing/SecondaryFeatures.stx +80 -0
  234. package/resources/components/Marketing/Testimonials.stx +120 -0
  235. package/resources/components/Pagination.stx +149 -0
  236. package/resources/components/Playground.stx +9 -0
  237. package/resources/components/README.md +87 -0
  238. package/resources/components/Storefront/AnnouncementBar.stx +17 -0
  239. package/resources/components/Storefront/CartDrawer.stx +227 -0
  240. package/resources/components/Storefront/CheckoutSteps.stx +50 -0
  241. package/resources/components/Storefront/Footer.stx +81 -0
  242. package/resources/components/Storefront/Header.stx +66 -0
  243. package/resources/components/Storefront/OrderSummary.stx +79 -0
  244. package/resources/components/Storefront/ProductCard.stx +63 -0
  245. package/resources/components/Storefront/ProductGallery.stx +88 -0
  246. package/resources/components/Storefront/QuantitySelector.stx +97 -0
  247. package/resources/components/index.ts +13 -0
  248. package/resources/emails/email-verification.stx +46 -0
  249. package/resources/emails/layouts/base.html +113 -0
  250. package/resources/emails/password-changed.stx +55 -0
  251. package/resources/emails/password-reset.stx +43 -0
  252. package/resources/emails/subscription-confirmation.stx +99 -0
  253. package/resources/emails/welcome.stx +48 -0
  254. package/resources/functions/README.md +28 -0
  255. package/resources/functions/counter.ts +13 -0
  256. package/resources/functions/dark.ts +4 -0
  257. package/resources/functions/dashboard/analytics.ts +238 -0
  258. package/resources/functions/dashboard/config-io.ts +316 -0
  259. package/resources/functions/dashboard/data.ts +375 -0
  260. package/resources/functions/dashboard/library.ts +250 -0
  261. package/resources/functions/dashboard/sidebar.ts +537 -0
  262. package/resources/functions/dashboard/system.ts +150 -0
  263. package/resources/layouts/403.stx +188 -0
  264. package/resources/layouts/404.stx +34 -0
  265. package/resources/layouts/README.md +14 -0
  266. package/resources/layouts/dashboard/default.stx +20 -0
  267. package/resources/layouts/dashboard/guest.stx +10 -0
  268. package/resources/layouts/default.stx +3 -0
  269. package/resources/layouts/maintenance.stx +11 -0
  270. package/resources/layouts/storefront.stx +73 -0
  271. package/resources/layouts/system-tray/default.stx +3 -0
  272. package/resources/modules/.gitkeep +0 -0
  273. package/resources/plugins/.gitkeep +0 -0
  274. package/resources/plugins/preloader.ts +308 -0
  275. package/resources/types/README.md +3 -0
  276. package/resources/views/cart.stx +149 -0
  277. package/resources/views/checkout/contact.stx +101 -0
  278. package/resources/views/checkout/payment.stx +114 -0
  279. package/resources/views/checkout/shipping.stx +120 -0
  280. package/resources/views/coming-soon.stx +9 -0
  281. package/resources/views/dashboard/custom-page.stx +45 -0
  282. package/resources/views/dashboard/index.stx +24 -0
  283. package/resources/views/emails/welcome.stx +0 -0
  284. package/resources/views/errors/tester.stx +26 -0
  285. package/resources/views/index.stx +2865 -0
  286. package/resources/views/orders/[id].stx +130 -0
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Read & write `config/*.ts` files from the dashboard.
3
+ *
4
+ * Why this exists
5
+ * ---------------
6
+ * Config files use `export default { ... } satisfies XConfig` and pull
7
+ * defaults from `env.*`. The settings page needs to:
8
+ * - List every config file
9
+ * - Show its currently-resolved values (env-substituted)
10
+ * - Persist edits back to the source `.ts` so they survive restarts
11
+ *
12
+ * Reading is straightforward: `await import(file)` returns the evaluated
13
+ * object. Writing is harder — we want to preserve comments, type imports,
14
+ * and the `env.*` references intact. Instead of regenerating the file,
15
+ * we mutate the source text in place: find the line containing
16
+ * `<key>: <oldValue>` and rewrite the value. This keeps the file
17
+ * round-trippable and human-readable.
18
+ *
19
+ * Edits supported (top-level scalar keys only):
20
+ * - string → string (single- or double-quoted)
21
+ * - number → number
22
+ * - boolean → boolean
23
+ *
24
+ * Edits NOT supported (the settings UI surfaces a read-only badge):
25
+ * - nested objects, arrays
26
+ * - keys whose current value is a function call or expression
27
+ * (`env.X ?? 'y'`) — those need to be edited via env, not config
28
+ */
29
+
30
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
31
+ import { join, resolve } from 'node:path'
32
+ import process from 'node:process'
33
+
34
+ let projectRootCache: string | null = null
35
+ function projectRoot(): string {
36
+ if (projectRootCache) return projectRootCache
37
+ let dir = process.cwd()
38
+ for (let i = 0; i < 8; i++) {
39
+ if (existsSync(resolve(dir, 'config'))) {
40
+ projectRootCache = dir
41
+ return dir
42
+ }
43
+ const parent = resolve(dir, '..')
44
+ if (parent === dir) break
45
+ dir = parent
46
+ }
47
+ projectRootCache = process.cwd()
48
+ return projectRootCache
49
+ }
50
+
51
+ function configDir(): string {
52
+ return resolve(projectRoot(), 'config')
53
+ }
54
+
55
+ export interface ConfigFileSummary {
56
+ /** Filename without extension, e.g. 'email'. */
57
+ name: string
58
+ /** Display title, e.g. 'Email'. */
59
+ title: string
60
+ /** Absolute path to the source file. */
61
+ path: string
62
+ /** Bytes — UI uses this to show "small / large config" hints. */
63
+ size: number
64
+ }
65
+
66
+ /** Enumerate every `.ts` config file. Hidden / non-ts files are skipped. */
67
+ export function listConfigFiles(): ConfigFileSummary[] {
68
+ const dir = configDir()
69
+ if (!existsSync(dir)) return []
70
+ let entries: string[]
71
+ try { entries = readdirSync(dir) }
72
+ catch { return [] }
73
+ const out: ConfigFileSummary[] = []
74
+ for (const e of entries) {
75
+ if (!e.endsWith('.ts') || e.startsWith('.')) continue
76
+ const path = join(dir, e)
77
+ let size = 0
78
+ try { size = Bun.file(path).size }
79
+ catch { /* unreadable — leave at 0 */ }
80
+ const name = e.replace(/\.ts$/, '')
81
+ out.push({ name, title: titleCase(name), path, size })
82
+ }
83
+ return out.sort((a, b) => a.name.localeCompare(b.name))
84
+ }
85
+
86
+ /**
87
+ * Cache of imported config modules. We re-import on every call when the
88
+ * file's mtime has changed so the dashboard reflects edits immediately,
89
+ * but skip the cost when nothing has changed in a tight burst of reads.
90
+ */
91
+ const moduleCache = new Map<string, { mtimeMs: number, value: any }>()
92
+
93
+ export interface ReadResult {
94
+ /** The fully-resolved default export, with env values substituted. */
95
+ values: any
96
+ /** Raw source text (used by the editor + the writer). */
97
+ source: string
98
+ /** Per-key metadata: what's editable, what's read-only and why. */
99
+ fields: ConfigField[]
100
+ }
101
+
102
+ export interface ConfigField {
103
+ key: string
104
+ /** The currently-resolved value (post env substitution). */
105
+ value: any
106
+ /** 'string' | 'number' | 'boolean' | 'object' | 'array' | 'null' */
107
+ type: string
108
+ /** True when this scalar can be safely round-tripped on write. */
109
+ editable: boolean
110
+ /** Human-readable reason when editable === false. */
111
+ reason?: string
112
+ }
113
+
114
+ /** Read one config file by name (e.g. 'email'). */
115
+ export async function readConfig(name: string): Promise<ReadResult | null> {
116
+ const path = join(configDir(), `${name}.ts`)
117
+ if (!existsSync(path)) return null
118
+
119
+ const stat = await Bun.file(path).stat?.().catch(() => null)
120
+ const mtimeMs = stat?.mtimeMs ?? Date.now()
121
+
122
+ // Read the source as the source-of-truth for the field listing.
123
+ // We deliberately do NOT re-`import()` the file after every edit — Bun
124
+ // caches modules by URL, query-string cache-busting (`?t=…`) caused
125
+ // intermittent 0-status responses for some configs (likely when the
126
+ // resolved value held a function or unsupported sentinel). The source
127
+ // text + a one-off initial import gives us everything the editor
128
+ // needs without re-executing the config graph on every keystroke.
129
+ const source = readFileSync(path, 'utf8')
130
+
131
+ let modVal: any
132
+ const cached = moduleCache.get(path)
133
+ if (cached) {
134
+ modVal = cached.value
135
+ }
136
+ else {
137
+ try {
138
+ const mod = await import(path)
139
+ modVal = mod?.default ?? mod
140
+ moduleCache.set(path, { mtimeMs, value: modVal })
141
+ }
142
+ catch (err) {
143
+ throw new Error(`Failed to load config/${name}.ts: ${(err as Error).message}`)
144
+ }
145
+ }
146
+
147
+ // For previously-cached modules, refresh scalar fields from the source
148
+ // text instead of re-importing — that way edits made via this API are
149
+ // reflected immediately in subsequent reads without paying the import
150
+ // re-evaluation cost (and without tripping Bun's URL cache).
151
+ modVal = applySourceOverrides(modVal, source)
152
+ const fields = describeFields(modVal, source)
153
+ return { values: modVal, source, fields }
154
+ }
155
+
156
+ /**
157
+ * Update a single top-level key in a config file. Returns the rewritten
158
+ * source on success, or throws when the key is non-editable (nested,
159
+ * env-backed, etc.) — caller should surface that to the UI.
160
+ */
161
+ export interface UpdateResult {
162
+ ok: boolean
163
+ newValue: any
164
+ source: string
165
+ }
166
+
167
+ export async function updateConfigKey(
168
+ name: string,
169
+ key: string,
170
+ newValue: string | number | boolean,
171
+ ): Promise<UpdateResult> {
172
+ const path = join(configDir(), `${name}.ts`)
173
+ if (!existsSync(path))
174
+ throw new Error(`config/${name}.ts not found`)
175
+
176
+ const source = readFileSync(path, 'utf8')
177
+ const rewritten = rewriteKey(source, key, newValue)
178
+ if (!rewritten)
179
+ throw new Error(`Cannot edit "${key}" in config/${name}.ts — key is not a top-level scalar literal (likely env-backed or nested).`)
180
+
181
+ writeFileSync(path, rewritten, 'utf8')
182
+ // We don't bust moduleCache here on purpose — re-importing with a
183
+ // changed-on-disk file via Bun reuses the original module from the
184
+ // first import; subsequent reads pick up the edit through the source-
185
+ // text overlay (`applySourceOverrides`) instead.
186
+ return { ok: true, newValue, source: rewritten }
187
+ }
188
+
189
+ /**
190
+ * Walk the source text for top-level scalar literal assignments and
191
+ * overlay them onto the given values object. This lets us reflect
192
+ * post-write edits without paying the cost (and risk) of re-importing
193
+ * the whole config module after every keystroke.
194
+ */
195
+ function applySourceOverrides(values: any, source: string): any {
196
+ if (!values || typeof values !== 'object' || Array.isArray(values)) return values
197
+ const out: Record<string, any> = { ...values }
198
+ for (const key of Object.keys(out)) {
199
+ const re = scalarLiteralRegex(key)
200
+ const m = re.exec(source)
201
+ if (!m) continue
202
+ const literal = m[4]
203
+ const parsed = parseLiteral(literal)
204
+ if (parsed !== UNSUPPORTED) out[key] = parsed
205
+ }
206
+ return out
207
+ }
208
+
209
+ const UNSUPPORTED = Symbol('unsupported')
210
+ function parseLiteral(literal: string): any {
211
+ if (literal === 'true') return true
212
+ if (literal === 'false') return false
213
+ if (literal === 'null') return null
214
+ if (/^-?\d+(?:\.\d+)?$/.test(literal)) return Number(literal)
215
+ if ((literal.startsWith('\'') && literal.endsWith('\'')) || (literal.startsWith('"') && literal.endsWith('"'))) {
216
+ return literal.slice(1, -1)
217
+ }
218
+ return UNSUPPORTED
219
+ }
220
+
221
+ /* -------------------------------------------------------------------------- */
222
+ /* Internals */
223
+ /* -------------------------------------------------------------------------- */
224
+
225
+ function titleCase(name: string): string {
226
+ return name
227
+ .split(/[-_]/)
228
+ .map(s => s.charAt(0).toUpperCase() + s.slice(1))
229
+ .join(' ')
230
+ }
231
+
232
+ function describeFields(values: any, source: string): ConfigField[] {
233
+ if (!values || typeof values !== 'object' || Array.isArray(values)) return []
234
+ const out: ConfigField[] = []
235
+ for (const [key, value] of Object.entries(values)) {
236
+ const type = typeOf(value)
237
+ const editable = isScalar(type)
238
+ let reason: string | undefined
239
+ if (!editable) {
240
+ reason = type === 'object' || type === 'array'
241
+ ? 'Nested values can be edited by hand in the file'
242
+ : 'Unsupported type'
243
+ }
244
+ else if (!hasScalarLiteral(source, key)) {
245
+ // The source value is a non-literal expression like `env.FOO ?? 'x'`.
246
+ // We can't safely rewrite it without losing the env fallback.
247
+ // Mark as read-only and tell the user to set the env var instead.
248
+ out.push({
249
+ key,
250
+ value,
251
+ type,
252
+ editable: false,
253
+ reason: 'Defined via env var or expression — set the env variable to change',
254
+ })
255
+ continue
256
+ }
257
+ out.push({ key, value, type, editable, reason })
258
+ }
259
+ return out
260
+ }
261
+
262
+ function typeOf(v: any): string {
263
+ if (v === null) return 'null'
264
+ if (Array.isArray(v)) return 'array'
265
+ return typeof v
266
+ }
267
+
268
+ function isScalar(t: string): boolean {
269
+ return t === 'string' || t === 'number' || t === 'boolean'
270
+ }
271
+
272
+ /**
273
+ * Detects whether a top-level key in the export-default block is set
274
+ * directly to a scalar literal — `key: 'value'`, `key: 42`, `key: true`.
275
+ *
276
+ * The match is intentionally conservative: it only succeeds when the
277
+ * value following the colon is a single string/number/boolean literal,
278
+ * optionally followed by a comma. Anything more complex
279
+ * (`env.X ?? 'y'`, `[1, 2]`, `{ a: 1 }`, multi-line arrays, etc.)
280
+ * fails so we don't accidentally clobber an expression.
281
+ */
282
+ function hasScalarLiteral(source: string, key: string): boolean {
283
+ const re = scalarLiteralRegex(key)
284
+ return re.test(source)
285
+ }
286
+
287
+ function scalarLiteralRegex(key: string): RegExp {
288
+ const k = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
289
+ // Matches:
290
+ // <indent><key>: 'value' OR "value" OR 123 OR true/false/null
291
+ // followed by optional comma + line-ending. The line MUST not contain
292
+ // operators (??, ||, &&, +, ?), function calls, or sub-objects.
293
+ return new RegExp(
294
+ `^(\\s*)(${k})(\\s*:\\s*)('[^'\\n]*'|"[^"\\n]*"|-?\\d+(?:\\.\\d+)?|true|false|null)(\\s*,?\\s*)$`,
295
+ 'm',
296
+ )
297
+ }
298
+
299
+ function rewriteKey(source: string, key: string, newValue: string | number | boolean): string | null {
300
+ const re = scalarLiteralRegex(key)
301
+ if (!re.test(source)) return null
302
+ const literal = serializeScalar(newValue)
303
+ return source.replace(re, (_full, indent, k, sep, _old, tail) => {
304
+ return `${indent}${k}${sep}${literal}${tail}`
305
+ })
306
+ }
307
+
308
+ function serializeScalar(v: string | number | boolean): string {
309
+ if (typeof v === 'string') {
310
+ // Prefer single quotes; fall back to double if the value contains a
311
+ // bare apostrophe (avoid escape gymnastics).
312
+ if (v.includes('\'') && !v.includes('"')) return `"${v}"`
313
+ return `'${v.replace(/'/g, '\\\'')}'`
314
+ }
315
+ return String(v)
316
+ }
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Shared dashboard server-side helpers.
3
+ *
4
+ * Dashboard `.stx` pages run a `<script server>` block at request time,
5
+ * import the framework's ORM, query for data, and shape it for the
6
+ * template. Several pages need the same primitives — a safe model load
7
+ * (the orm package only re-exports User/Job/FailedJob, the rest must be
8
+ * loaded by file path), null-tolerant getters, and small aggregation
9
+ * helpers — so they live here instead of being copy-pasted into every
10
+ * page header.
11
+ *
12
+ * Usage from inside a page's `<script server>`:
13
+ *
14
+ * const { loadModel, safeAll, safeGet, countBy } =
15
+ * await import('../../../resources/functions/dashboard/data')
16
+ * const Order = await loadModel('Order')
17
+ * const orders = await safeAll(Order)
18
+ * const byStatus = countBy(orders, 'status')
19
+ */
20
+
21
+ import { existsSync } from 'node:fs'
22
+ import { dirname, resolve } from 'node:path'
23
+
24
+ let projectRootCache: string | null = null
25
+
26
+ function projectRoot(): string {
27
+ if (projectRootCache) return projectRootCache
28
+ // Walk up from this file location to find the project root (the dir
29
+ // that contains `storage/framework/defaults`). This mirrors what
30
+ // `@stacksjs/path` does, but without pulling in the path package
31
+ // (which has its own evaluation graph and would slow page renders).
32
+ let dir = dirname(new URL(import.meta.url).pathname)
33
+ for (let i = 0; i < 12; i++) {
34
+ if (existsSync(resolve(dir, 'storage/framework/defaults'))) {
35
+ projectRootCache = dir
36
+ return dir
37
+ }
38
+ const parent = dirname(dir)
39
+ if (parent === dir) break
40
+ dir = parent
41
+ }
42
+ // Fall back to cwd — STX serves run from the project root.
43
+ projectRootCache = process.cwd()
44
+ return projectRootCache
45
+ }
46
+
47
+ /**
48
+ * Map of model name → list of relative paths to try, in order. The first
49
+ * existing file wins. User overrides at `app/Models/<Name>.ts` always
50
+ * take priority over framework defaults. The defaults are organised in
51
+ * subdirectories (commerce/, Content/, realtime/) so each model needs an
52
+ * explicit lookup table — globbing at request time would add ~50ms per
53
+ * page render.
54
+ */
55
+ const MODEL_PATHS: Record<string, string[]> = {
56
+ // Auth / core
57
+ User: ['app/Models/User.ts', 'storage/framework/defaults/app/Models/User.ts'],
58
+ Team: ['app/Models/Team.ts', 'storage/framework/defaults/app/Models/Team.ts'],
59
+ Subscriber: ['app/Models/Subscriber.ts', 'storage/framework/defaults/app/Models/Subscriber.ts'],
60
+ SubscriberEmail: ['app/Models/SubscriberEmail.ts', 'storage/framework/defaults/app/Models/SubscriberEmail.ts'],
61
+ Subscription: ['app/Models/Subscription.ts', 'storage/framework/defaults/app/Models/Subscription.ts'],
62
+
63
+ // Content
64
+ Post: ['app/Models/Post.ts', 'storage/framework/defaults/app/Models/Content/Post.ts'],
65
+ Page: ['app/Models/Page.ts', 'storage/framework/defaults/app/Models/Content/Page.ts'],
66
+ Author: ['app/Models/Author.ts', 'storage/framework/defaults/app/Models/Content/Author.ts'],
67
+ Comment: ['app/Models/Comment.ts', 'storage/framework/defaults/app/Models/Comment.ts'],
68
+ Tag: ['app/Models/Tag.ts', 'storage/framework/defaults/app/Models/Tag.ts'],
69
+ Category: ['app/Models/Category.ts', 'storage/framework/defaults/app/Models/commerce/Category.ts'],
70
+
71
+ // App / operations
72
+ Activity: ['app/Models/Activity.ts', 'storage/framework/defaults/app/Models/Activity.ts'],
73
+ Deployment: ['app/Models/Deployment.ts', 'storage/framework/defaults/app/Models/Deployment.ts'],
74
+ Release: ['app/Models/Release.ts', 'storage/framework/defaults/app/Models/Release.ts'],
75
+ Notification: ['app/Models/Notification.ts', 'storage/framework/defaults/app/Models/Notification.ts'],
76
+ Job: ['app/Models/Job.ts', 'storage/framework/defaults/app/Models/Job.ts'],
77
+ FailedJob: ['app/Models/FailedJob.ts', 'storage/framework/defaults/app/Models/FailedJob.ts'],
78
+ Log: ['app/Models/Log.ts', 'storage/framework/defaults/app/Models/Log.ts'],
79
+ Request: ['app/Models/Request.ts', 'storage/framework/defaults/app/Models/Request.ts'],
80
+ Error: ['app/Models/Error.ts', 'storage/framework/defaults/app/Models/Error.ts'],
81
+ Websocket: ['app/Models/Websocket.ts', 'storage/framework/defaults/app/Models/realtime/Websocket.ts'],
82
+
83
+ // Marketing
84
+ Campaign: ['app/Models/Campaign.ts', 'storage/framework/defaults/app/Models/Campaign.ts'],
85
+ EmailList: ['app/Models/EmailList.ts', 'storage/framework/defaults/app/Models/EmailList.ts'],
86
+ SocialPost: ['app/Models/SocialPost.ts', 'storage/framework/defaults/app/Models/SocialPost.ts'],
87
+
88
+ // Commerce
89
+ Order: ['app/Models/Order.ts', 'storage/framework/defaults/app/Models/commerce/Order.ts'],
90
+ OrderItem: ['app/Models/OrderItem.ts', 'storage/framework/defaults/app/Models/commerce/OrderItem.ts'],
91
+ Cart: ['app/Models/Cart.ts', 'storage/framework/defaults/app/Models/commerce/Cart.ts'],
92
+ CartItem: ['app/Models/CartItem.ts', 'storage/framework/defaults/app/Models/commerce/CartItem.ts'],
93
+ Customer: ['app/Models/Customer.ts', 'storage/framework/defaults/app/Models/commerce/Customer.ts'],
94
+ Product: ['app/Models/Product.ts', 'storage/framework/defaults/app/Models/commerce/Product.ts'],
95
+ ProductVariant: ['app/Models/ProductVariant.ts', 'storage/framework/defaults/app/Models/commerce/ProductVariant.ts'],
96
+ ProductUnit: ['app/Models/ProductUnit.ts', 'storage/framework/defaults/app/Models/commerce/ProductUnit.ts'],
97
+ Manufacturer: ['app/Models/Manufacturer.ts', 'storage/framework/defaults/app/Models/commerce/Manufacturer.ts'],
98
+ Coupon: ['app/Models/Coupon.ts', 'storage/framework/defaults/app/Models/commerce/Coupon.ts'],
99
+ GiftCard: ['app/Models/GiftCard.ts', 'storage/framework/defaults/app/Models/commerce/GiftCard.ts'],
100
+ LicenseKey: ['app/Models/LicenseKey.ts', 'storage/framework/defaults/app/Models/commerce/LicenseKey.ts'],
101
+ Review: ['app/Models/Review.ts', 'storage/framework/defaults/app/Models/commerce/Review.ts'],
102
+ Receipt: ['app/Models/Receipt.ts', 'storage/framework/defaults/app/Models/commerce/Receipt.ts'],
103
+ PrintDevice: ['app/Models/PrintDevice.ts', 'storage/framework/defaults/app/Models/commerce/PrintDevice.ts'],
104
+ ShippingMethod: ['app/Models/ShippingMethod.ts', 'storage/framework/defaults/app/Models/commerce/ShippingMethod.ts'],
105
+ ShippingRate: ['app/Models/ShippingRate.ts', 'storage/framework/defaults/app/Models/commerce/ShippingRate.ts'],
106
+ ShippingZone: ['app/Models/ShippingZone.ts', 'storage/framework/defaults/app/Models/commerce/ShippingZone.ts'],
107
+ DeliveryRoute: ['app/Models/DeliveryRoute.ts', 'storage/framework/defaults/app/Models/commerce/DeliveryRoute.ts'],
108
+ DigitalDelivery: ['app/Models/DigitalDelivery.ts', 'storage/framework/defaults/app/Models/commerce/DigitalDelivery.ts'],
109
+ Driver: ['app/Models/Driver.ts', 'storage/framework/defaults/app/Models/commerce/Driver.ts'],
110
+ TaxRate: ['app/Models/TaxRate.ts', 'storage/framework/defaults/app/Models/commerce/TaxRate.ts'],
111
+ WaitlistProduct: ['app/Models/WaitlistProduct.ts', 'storage/framework/defaults/app/Models/commerce/WaitlistProduct.ts'],
112
+ WaitlistRestaurant: ['app/Models/WaitlistRestaurant.ts', 'storage/framework/defaults/app/Models/commerce/WaitlistRestaurant.ts'],
113
+ LoyaltyPoint: ['app/Models/LoyaltyPoint.ts', 'storage/framework/defaults/app/Models/commerce/LoyaltyPoint.ts'],
114
+ LoyaltyReward: ['app/Models/LoyaltyReward.ts', 'storage/framework/defaults/app/Models/commerce/LoyaltyReward.ts'],
115
+ Payment: ['app/Models/Payment.ts', 'storage/framework/defaults/app/Models/commerce/Payment.ts'],
116
+ PaymentMethod: ['app/Models/PaymentMethod.ts', 'storage/framework/defaults/app/Models/PaymentMethod.ts'],
117
+ PaymentProduct: ['app/Models/PaymentProduct.ts', 'storage/framework/defaults/app/Models/PaymentProduct.ts'],
118
+ PaymentTransaction: ['app/Models/PaymentTransaction.ts', 'storage/framework/defaults/app/Models/PaymentTransaction.ts'],
119
+ Transaction: ['app/Models/Transaction.ts', 'storage/framework/defaults/app/Models/commerce/Transaction.ts'],
120
+ }
121
+
122
+ const modelCache = new Map<string, any>()
123
+
124
+ /**
125
+ * Load a model class by name, searching userland first then framework
126
+ * defaults. Returns a stub with no-op `.all()` / `.orderBy()` / `.count()`
127
+ * methods if the model file doesn't exist or fails to import — pages can
128
+ * always call query methods on the result without try/catch around the
129
+ * load itself.
130
+ */
131
+ export async function loadModel(name: string): Promise<any> {
132
+ if (modelCache.has(name)) return modelCache.get(name)
133
+
134
+ const candidates = MODEL_PATHS[name]
135
+ if (!candidates) {
136
+ const stub = makeStub(name)
137
+ modelCache.set(name, stub)
138
+ return stub
139
+ }
140
+
141
+ const root = projectRoot()
142
+ for (const rel of candidates) {
143
+ const abs = resolve(root, rel)
144
+ if (!existsSync(abs)) continue
145
+ try {
146
+ const mod = await import(abs)
147
+ const M = mod?.default ?? mod
148
+ if (M) {
149
+ modelCache.set(name, M)
150
+ return M
151
+ }
152
+ }
153
+ catch {
154
+ // Try the next candidate path; final fallback is the stub below.
155
+ }
156
+ }
157
+
158
+ const stub = makeStub(name)
159
+ modelCache.set(name, stub)
160
+ return stub
161
+ }
162
+
163
+ /**
164
+ * Load several models at once. Resolves to a record so callers can
165
+ * destructure: `const { Order, Product } = await loadModels(['Order', 'Product'])`.
166
+ */
167
+ export async function loadModels<T extends string>(names: T[]): Promise<Record<T, any>> {
168
+ const out = {} as Record<T, any>
169
+ await Promise.all(names.map(async (n) => {
170
+ out[n] = await loadModel(n)
171
+ }))
172
+ return out
173
+ }
174
+
175
+ function makeStub(_name: string): any {
176
+ const empty: any[] = []
177
+ const chain: any = {
178
+ get: async () => empty,
179
+ all: async () => empty,
180
+ count: async () => 0,
181
+ first: async () => null,
182
+ find: async () => null,
183
+ where: () => chain,
184
+ orderBy: () => chain,
185
+ orderByDesc: () => chain,
186
+ limit: () => chain,
187
+ take: () => chain,
188
+ skip: () => chain,
189
+ select: () => chain,
190
+ distinct: () => chain,
191
+ groupBy: () => chain,
192
+ whereIn: () => chain,
193
+ whereNotNull: () => chain,
194
+ whereNull: () => chain,
195
+ _isStub: true,
196
+ }
197
+ return chain
198
+ }
199
+
200
+ /**
201
+ * Run `Model.all()` and always resolve to an array — never throws, never
202
+ * returns undefined. Use this whenever you need every row of a table for
203
+ * the page (the seeder caps each table at ~50 rows so the cost is fine).
204
+ */
205
+ export async function safeAll(Model: any): Promise<any[]> {
206
+ if (!Model) return []
207
+ try {
208
+ if (typeof Model.all === 'function') {
209
+ const rows = await Model.all()
210
+ return Array.isArray(rows) ? rows : []
211
+ }
212
+ if (typeof Model.get === 'function') {
213
+ const rows = await Model.get()
214
+ return Array.isArray(rows) ? rows : []
215
+ }
216
+ }
217
+ catch {
218
+ // DB unreachable / table missing / model schema mismatch — page
219
+ // should render an empty state, not a 500.
220
+ }
221
+ return []
222
+ }
223
+
224
+ /**
225
+ * Run `Model.count()` and always resolve to a number — never throws. Falls
226
+ * back to `safeAll(Model).length` when the model has no `.count` method
227
+ * (rare, but the makeStub stub provides one so this is mostly defensive).
228
+ */
229
+ export async function safeCount(Model: any): Promise<number> {
230
+ if (!Model) return 0
231
+ try {
232
+ if (typeof Model.count === 'function') {
233
+ const n = await Model.count()
234
+ return typeof n === 'number' ? n : 0
235
+ }
236
+ }
237
+ catch {
238
+ // count() may fail if the table doesn't exist; let safeAll absorb that
239
+ // by returning [] and we'll report 0 records.
240
+ }
241
+ const rows = await safeAll(Model)
242
+ return rows.length
243
+ }
244
+
245
+ /**
246
+ * Read a column off a model row, tolerating both proxy-style rows
247
+ * (`row.get('name')`) and plain object rows (`row.name`). Returns the
248
+ * fallback when the column is missing or null/undefined.
249
+ */
250
+ export function safeGet(row: any, key: string, fallback: any = ''): any {
251
+ if (!row) return fallback
252
+ try {
253
+ if (typeof row.get === 'function') {
254
+ const v = row.get(key)
255
+ if (v !== undefined && v !== null) return v
256
+ }
257
+ }
258
+ catch { /* fall through to direct property access */ }
259
+ const direct = row[key]
260
+ return direct !== undefined && direct !== null ? direct : fallback
261
+ }
262
+
263
+ /** Group rows by the value of a column. */
264
+ export function countBy(rows: any[], key: string): Record<string, number> {
265
+ const out: Record<string, number> = {}
266
+ for (const r of rows) {
267
+ const v = String(safeGet(r, key, '') || 'unknown')
268
+ out[v] = (out[v] || 0) + 1
269
+ }
270
+ return out
271
+ }
272
+
273
+ /** Sum a numeric column across rows. */
274
+ export function sumBy(rows: any[], key: string): number {
275
+ let total = 0
276
+ for (const r of rows) {
277
+ const v = Number(safeGet(r, key, 0)) || 0
278
+ total += v
279
+ }
280
+ return total
281
+ }
282
+
283
+ /** Top N values of a column with their counts, sorted desc. */
284
+ export function topN(rows: any[], key: string, n = 5): Array<{ value: string, count: number }> {
285
+ const counts = countBy(rows, key)
286
+ return Object.entries(counts)
287
+ .map(([value, count]) => ({ value, count }))
288
+ .sort((a, b) => b.count - a.count)
289
+ .slice(0, n)
290
+ }
291
+
292
+ /**
293
+ * Group rows into buckets by ISO date. Returns a window of `days` from
294
+ * today backwards, with zero-counts for empty days so charts render a
295
+ * smooth line. Date column is parsed as `Date(value)`.
296
+ */
297
+ export function groupByDay(
298
+ rows: any[],
299
+ key: string,
300
+ days = 30,
301
+ ): Array<{ date: string, count: number }> {
302
+ const buckets: Record<string, number> = {}
303
+ const today = new Date()
304
+ today.setHours(0, 0, 0, 0)
305
+ for (let i = days - 1; i >= 0; i--) {
306
+ const d = new Date(today)
307
+ d.setDate(today.getDate() - i)
308
+ buckets[d.toISOString().slice(0, 10)] = 0
309
+ }
310
+ for (const r of rows) {
311
+ const raw = safeGet(r, key, null)
312
+ if (!raw) continue
313
+ const d = new Date(raw)
314
+ if (Number.isNaN(d.getTime())) continue
315
+ const k = d.toISOString().slice(0, 10)
316
+ if (k in buckets) buckets[k]++
317
+ }
318
+ return Object.entries(buckets).map(([date, count]) => ({ date, count }))
319
+ }
320
+
321
+ /**
322
+ * Sum a numeric column into per-day buckets. Same windowing semantics as
323
+ * `groupByDay` — useful for revenue-over-time charts.
324
+ */
325
+ export function sumByDay(
326
+ rows: any[],
327
+ dateKey: string,
328
+ valueKey: string,
329
+ days = 30,
330
+ ): Array<{ date: string, total: number }> {
331
+ const buckets: Record<string, number> = {}
332
+ const today = new Date()
333
+ today.setHours(0, 0, 0, 0)
334
+ for (let i = days - 1; i >= 0; i--) {
335
+ const d = new Date(today)
336
+ d.setDate(today.getDate() - i)
337
+ buckets[d.toISOString().slice(0, 10)] = 0
338
+ }
339
+ for (const r of rows) {
340
+ const raw = safeGet(r, dateKey, null)
341
+ if (!raw) continue
342
+ const d = new Date(raw)
343
+ if (Number.isNaN(d.getTime())) continue
344
+ const k = d.toISOString().slice(0, 10)
345
+ if (!(k in buckets)) continue
346
+ buckets[k] += Number(safeGet(r, valueKey, 0)) || 0
347
+ }
348
+ return Object.entries(buckets).map(([date, total]) => ({ date, total }))
349
+ }
350
+
351
+ /** "5m ago" / "2h ago" / "3d ago" style relative time. Stable on server. */
352
+ export function formatRelative(input: any): string {
353
+ if (!input) return ''
354
+ const d = new Date(input)
355
+ if (Number.isNaN(d.getTime())) return String(input)
356
+ const diff = Math.max(0, Date.now() - d.getTime())
357
+ const sec = Math.floor(diff / 1000)
358
+ if (sec < 60) return 'just now'
359
+ const min = Math.floor(sec / 60)
360
+ if (min < 60) return `${min}m ago`
361
+ const hr = Math.floor(min / 60)
362
+ if (hr < 24) return `${hr}h ago`
363
+ const day = Math.floor(hr / 24)
364
+ if (day < 30) return `${day}d ago`
365
+ const mo = Math.floor(day / 30)
366
+ if (mo < 12) return `${mo}mo ago`
367
+ const yr = Math.floor(mo / 12)
368
+ return `${yr}y ago`
369
+ }
370
+
371
+ /** Format any value as USD with no fractional cents for whole numbers. */
372
+ export function formatCurrency(value: any): string {
373
+ const n = Number(value) || 0
374
+ return `$${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
375
+ }