@kelpie/ui 0.12.0 → 0.14.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 (300) hide show
  1. package/dist/api/resources/attendances.d.ts +13 -0
  2. package/dist/api/resources/attendances.d.ts.map +1 -0
  3. package/dist/api/resources/attendances.js +34 -0
  4. package/dist/api/resources/attendances.js.map +1 -0
  5. package/dist/api/resources/eventAssociations.d.ts +12 -0
  6. package/dist/api/resources/eventAssociations.d.ts.map +1 -0
  7. package/dist/api/resources/eventAssociations.js +25 -0
  8. package/dist/api/resources/eventAssociations.js.map +1 -0
  9. package/dist/api/resources/events.d.ts +17 -0
  10. package/dist/api/resources/events.d.ts.map +1 -0
  11. package/dist/api/resources/events.js +40 -0
  12. package/dist/api/resources/events.js.map +1 -0
  13. package/dist/api/resources/handbookSeed.d.ts +11 -0
  14. package/dist/api/resources/handbookSeed.d.ts.map +1 -0
  15. package/dist/api/resources/handbookSeed.js +43 -0
  16. package/dist/api/resources/handbookSeed.js.map +1 -0
  17. package/dist/api/resources/moduleSettings.d.ts +10 -0
  18. package/dist/api/resources/moduleSettings.d.ts.map +1 -1
  19. package/dist/api/resources/moduleSettings.js +21 -0
  20. package/dist/api/resources/moduleSettings.js.map +1 -1
  21. package/dist/api/resources/planItems.d.ts +4 -4
  22. package/dist/api/resources/planItems.d.ts.map +1 -1
  23. package/dist/api/resources/planItems.js +1 -1
  24. package/dist/api/resources/planItems.js.map +1 -1
  25. package/dist/api/resources/publicConfig.js +3 -3
  26. package/dist/api/resources/sampleData.d.ts.map +1 -1
  27. package/dist/api/resources/sampleData.js +2 -0
  28. package/dist/api/resources/sampleData.js.map +1 -1
  29. package/dist/api/resources/session.d.ts +22 -1
  30. package/dist/api/resources/session.d.ts.map +1 -1
  31. package/dist/api/resources/session.js +43 -1
  32. package/dist/api/resources/session.js.map +1 -1
  33. package/dist/api/resources/workspace.d.ts.map +1 -1
  34. package/dist/api/resources/workspace.js +2 -3
  35. package/dist/api/resources/workspace.js.map +1 -1
  36. package/dist/app/KelpieApp.d.ts.map +1 -1
  37. package/dist/app/KelpieApp.js +5 -1
  38. package/dist/app/KelpieApp.js.map +1 -1
  39. package/dist/components/ActivitiesPanel.d.ts.map +1 -1
  40. package/dist/components/ActivitiesPanel.js +2 -0
  41. package/dist/components/ActivitiesPanel.js.map +1 -1
  42. package/dist/components/AddressesField.d.ts +23 -0
  43. package/dist/components/AddressesField.d.ts.map +1 -0
  44. package/dist/components/AddressesField.js +215 -0
  45. package/dist/components/AddressesField.js.map +1 -0
  46. package/dist/components/CountrySearch.d.ts +16 -0
  47. package/dist/components/CountrySearch.d.ts.map +1 -0
  48. package/dist/components/CountrySearch.js +30 -0
  49. package/dist/components/CountrySearch.js.map +1 -0
  50. package/dist/components/EnvironmentBanner.d.ts +0 -12
  51. package/dist/components/EnvironmentBanner.d.ts.map +1 -1
  52. package/dist/components/EnvironmentBanner.js +21 -6
  53. package/dist/components/EnvironmentBanner.js.map +1 -1
  54. package/dist/components/InlineEdit.d.ts +7 -1
  55. package/dist/components/InlineEdit.d.ts.map +1 -1
  56. package/dist/components/InlineEdit.js +8 -1
  57. package/dist/components/InlineEdit.js.map +1 -1
  58. package/dist/components/LinkedEventsPanel.d.ts +11 -0
  59. package/dist/components/LinkedEventsPanel.d.ts.map +1 -0
  60. package/dist/components/LinkedEventsPanel.js +18 -0
  61. package/dist/components/LinkedEventsPanel.js.map +1 -0
  62. package/dist/components/NotesPanel.d.ts +4 -6
  63. package/dist/components/NotesPanel.d.ts.map +1 -1
  64. package/dist/components/NotesPanel.js +39 -3
  65. package/dist/components/NotesPanel.js.map +1 -1
  66. package/dist/components/PlanAttention.d.ts.map +1 -1
  67. package/dist/components/PlanAttention.js +3 -2
  68. package/dist/components/PlanAttention.js.map +1 -1
  69. package/dist/components/PlanPanel.d.ts +3 -3
  70. package/dist/components/PlanPanel.d.ts.map +1 -1
  71. package/dist/components/Shell.d.ts.map +1 -1
  72. package/dist/components/Shell.js +8 -2
  73. package/dist/components/Shell.js.map +1 -1
  74. package/dist/components/SocialNetworkIcon.d.ts +17 -0
  75. package/dist/components/SocialNetworkIcon.d.ts.map +1 -0
  76. package/dist/components/SocialNetworkIcon.js +39 -0
  77. package/dist/components/SocialNetworkIcon.js.map +1 -0
  78. package/dist/components/SocialProfilesField.d.ts +8 -0
  79. package/dist/components/SocialProfilesField.d.ts.map +1 -1
  80. package/dist/components/SocialProfilesField.js +19 -5
  81. package/dist/components/SocialProfilesField.js.map +1 -1
  82. package/dist/components/TimezoneSearch.d.ts +21 -0
  83. package/dist/components/TimezoneSearch.d.ts.map +1 -0
  84. package/dist/components/TimezoneSearch.js +27 -0
  85. package/dist/components/TimezoneSearch.js.map +1 -0
  86. package/dist/components/WorkspaceSwitcher.d.ts +8 -0
  87. package/dist/components/WorkspaceSwitcher.d.ts.map +1 -0
  88. package/dist/components/WorkspaceSwitcher.js +67 -0
  89. package/dist/components/WorkspaceSwitcher.js.map +1 -0
  90. package/dist/components/environmentTone.d.ts +10 -0
  91. package/dist/components/environmentTone.d.ts.map +1 -0
  92. package/dist/components/environmentTone.js +35 -0
  93. package/dist/components/environmentTone.js.map +1 -0
  94. package/dist/index.d.ts +21 -4
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +12 -2
  97. package/dist/index.js.map +1 -1
  98. package/dist/lib/timezones.d.ts +17 -0
  99. package/dist/lib/timezones.d.ts.map +1 -0
  100. package/dist/lib/timezones.js +39 -0
  101. package/dist/lib/timezones.js.map +1 -0
  102. package/dist/pages/CompaniesPage.d.ts.map +1 -1
  103. package/dist/pages/CompaniesPage.js +12 -11
  104. package/dist/pages/CompaniesPage.js.map +1 -1
  105. package/dist/pages/CompanyDetail.d.ts.map +1 -1
  106. package/dist/pages/CompanyDetail.js +23 -12
  107. package/dist/pages/CompanyDetail.js.map +1 -1
  108. package/dist/pages/DealDetail.d.ts.map +1 -1
  109. package/dist/pages/DealDetail.js +5 -1
  110. package/dist/pages/DealDetail.js.map +1 -1
  111. package/dist/pages/DecisionsPage.d.ts.map +1 -1
  112. package/dist/pages/DecisionsPage.js +7 -0
  113. package/dist/pages/DecisionsPage.js.map +1 -1
  114. package/dist/pages/EnquiryDetail.d.ts.map +1 -1
  115. package/dist/pages/EnquiryDetail.js +5 -1
  116. package/dist/pages/EnquiryDetail.js.map +1 -1
  117. package/dist/pages/EventDetail.d.ts +2 -0
  118. package/dist/pages/EventDetail.d.ts.map +1 -0
  119. package/dist/pages/EventDetail.js +332 -0
  120. package/dist/pages/EventDetail.js.map +1 -0
  121. package/dist/pages/EventsPage.d.ts +2 -0
  122. package/dist/pages/EventsPage.d.ts.map +1 -0
  123. package/dist/pages/EventsPage.js +176 -0
  124. package/dist/pages/EventsPage.js.map +1 -0
  125. package/dist/pages/ListDetail.d.ts.map +1 -1
  126. package/dist/pages/ListDetail.js +17 -0
  127. package/dist/pages/ListDetail.js.map +1 -1
  128. package/dist/pages/OpportunityDetail.d.ts.map +1 -1
  129. package/dist/pages/OpportunityDetail.js +5 -1
  130. package/dist/pages/OpportunityDetail.js.map +1 -1
  131. package/dist/pages/PartnershipDetail.d.ts.map +1 -1
  132. package/dist/pages/PartnershipDetail.js +5 -1
  133. package/dist/pages/PartnershipDetail.js.map +1 -1
  134. package/dist/pages/PeoplePage.d.ts.map +1 -1
  135. package/dist/pages/PeoplePage.js +13 -5
  136. package/dist/pages/PeoplePage.js.map +1 -1
  137. package/dist/pages/PersonDetail.d.ts.map +1 -1
  138. package/dist/pages/PersonDetail.js +94 -28
  139. package/dist/pages/PersonDetail.js.map +1 -1
  140. package/dist/pages/PlanningPage.d.ts.map +1 -1
  141. package/dist/pages/PlanningPage.js +6 -3
  142. package/dist/pages/PlanningPage.js.map +1 -1
  143. package/dist/pages/RaiseDetail.d.ts.map +1 -1
  144. package/dist/pages/RaiseDetail.js +5 -1
  145. package/dist/pages/RaiseDetail.js.map +1 -1
  146. package/dist/pages/RoleDetail.d.ts.map +1 -1
  147. package/dist/pages/RoleDetail.js +4 -1
  148. package/dist/pages/RoleDetail.js.map +1 -1
  149. package/dist/pages/SearchPage.d.ts.map +1 -1
  150. package/dist/pages/SearchPage.js +2 -1
  151. package/dist/pages/SearchPage.js.map +1 -1
  152. package/dist/pages/account/PreferencesPage.d.ts.map +1 -1
  153. package/dist/pages/account/PreferencesPage.js +2 -28
  154. package/dist/pages/account/PreferencesPage.js.map +1 -1
  155. package/dist/pages/admin/DataPage.js +10 -9
  156. package/dist/pages/admin/DataPage.js.map +1 -1
  157. package/dist/pages/admin/FieldsPage.js +1 -1
  158. package/dist/pages/admin/FieldsPage.js.map +1 -1
  159. package/dist/pages/admin/ModulesPage.d.ts.map +1 -1
  160. package/dist/pages/admin/ModulesPage.js +1 -0
  161. package/dist/pages/admin/ModulesPage.js.map +1 -1
  162. package/dist/pages/admin/WorkspaceSettingsPage.d.ts +12 -0
  163. package/dist/pages/admin/WorkspaceSettingsPage.d.ts.map +1 -1
  164. package/dist/pages/admin/WorkspaceSettingsPage.js +2 -21
  165. package/dist/pages/admin/WorkspaceSettingsPage.js.map +1 -1
  166. package/dist/pages/auth/AuthLayout.d.ts +5 -4
  167. package/dist/pages/auth/AuthLayout.d.ts.map +1 -1
  168. package/dist/pages/auth/AuthLayout.js +7 -5
  169. package/dist/pages/auth/AuthLayout.js.map +1 -1
  170. package/dist/pages/auth/RegionSwitcher.d.ts +10 -0
  171. package/dist/pages/auth/RegionSwitcher.d.ts.map +1 -0
  172. package/dist/pages/auth/RegionSwitcher.js +28 -0
  173. package/dist/pages/auth/RegionSwitcher.js.map +1 -0
  174. package/dist/pages/auth/SignInPage.d.ts.map +1 -1
  175. package/dist/pages/auth/SignInPage.js +3 -1
  176. package/dist/pages/auth/SignInPage.js.map +1 -1
  177. package/dist/pages/auth/SignUpPage.d.ts.map +1 -1
  178. package/dist/pages/auth/SignUpPage.js +7 -0
  179. package/dist/pages/auth/SignUpPage.js.map +1 -1
  180. package/dist/pages/auth/regionHref.d.ts +6 -0
  181. package/dist/pages/auth/regionHref.d.ts.map +1 -0
  182. package/dist/pages/auth/regionHref.js +8 -0
  183. package/dist/pages/auth/regionHref.js.map +1 -0
  184. package/dist/pages/dashboard/DashboardPage.d.ts +8 -0
  185. package/dist/pages/dashboard/DashboardPage.d.ts.map +1 -1
  186. package/dist/pages/dashboard/DashboardPage.js +5 -13
  187. package/dist/pages/dashboard/DashboardPage.js.map +1 -1
  188. package/dist/pages/dashboard/attention.d.ts +1 -8
  189. package/dist/pages/dashboard/attention.d.ts.map +1 -1
  190. package/dist/pages/dashboard/attention.js +21 -54
  191. package/dist/pages/dashboard/attention.js.map +1 -1
  192. package/dist/pages/dashboard/sections.d.ts.map +1 -1
  193. package/dist/pages/dashboard/sections.js +3 -2
  194. package/dist/pages/dashboard/sections.js.map +1 -1
  195. package/dist/pages/forms/FormActions.d.ts.map +1 -1
  196. package/dist/pages/forms/FormActions.js +7 -3
  197. package/dist/pages/forms/FormActions.js.map +1 -1
  198. package/dist/pages/onboarding/HandbookStepPage.d.ts +4 -8
  199. package/dist/pages/onboarding/HandbookStepPage.d.ts.map +1 -1
  200. package/dist/pages/onboarding/HandbookStepPage.js +13 -13
  201. package/dist/pages/onboarding/HandbookStepPage.js.map +1 -1
  202. package/dist/pages/onboarding/InvitesStepPage.d.ts.map +1 -1
  203. package/dist/pages/onboarding/InvitesStepPage.js +13 -7
  204. package/dist/pages/onboarding/InvitesStepPage.js.map +1 -1
  205. package/dist/pages/onboarding/ModulesStepPage.d.ts +11 -0
  206. package/dist/pages/onboarding/ModulesStepPage.d.ts.map +1 -0
  207. package/dist/pages/onboarding/ModulesStepPage.js +80 -0
  208. package/dist/pages/onboarding/ModulesStepPage.js.map +1 -0
  209. package/dist/pages/onboarding/OnboardingNav.d.ts +17 -0
  210. package/dist/pages/onboarding/OnboardingNav.d.ts.map +1 -0
  211. package/dist/pages/onboarding/OnboardingNav.js +37 -0
  212. package/dist/pages/onboarding/OnboardingNav.js.map +1 -0
  213. package/dist/pages/onboarding/OrganisationStepPage.d.ts +13 -0
  214. package/dist/pages/onboarding/OrganisationStepPage.d.ts.map +1 -0
  215. package/dist/pages/onboarding/OrganisationStepPage.js +59 -0
  216. package/dist/pages/onboarding/OrganisationStepPage.js.map +1 -0
  217. package/dist/pages/onboarding/WorkspaceStepPage.d.ts.map +1 -1
  218. package/dist/pages/onboarding/WorkspaceStepPage.js +25 -20
  219. package/dist/pages/onboarding/WorkspaceStepPage.js.map +1 -1
  220. package/dist/pages/onboarding/handbookTemplates.d.ts +19 -0
  221. package/dist/pages/onboarding/handbookTemplates.d.ts.map +1 -0
  222. package/dist/pages/onboarding/handbookTemplates.js +57 -0
  223. package/dist/pages/onboarding/handbookTemplates.js.map +1 -0
  224. package/dist/pages/onboarding/onboardingModules.d.ts +23 -0
  225. package/dist/pages/onboarding/onboardingModules.d.ts.map +1 -0
  226. package/dist/pages/onboarding/onboardingModules.js +153 -0
  227. package/dist/pages/onboarding/onboardingModules.js.map +1 -0
  228. package/dist/pages/onboarding/onboardingRerun.d.ts +21 -0
  229. package/dist/pages/onboarding/onboardingRerun.d.ts.map +1 -0
  230. package/dist/pages/onboarding/onboardingRerun.js +33 -0
  231. package/dist/pages/onboarding/onboardingRerun.js.map +1 -0
  232. package/package.json +4 -3
  233. package/src/api/resources/attendances.ts +54 -0
  234. package/src/api/resources/eventAssociations.ts +43 -0
  235. package/src/api/resources/events.ts +70 -0
  236. package/src/api/resources/handbookSeed.ts +63 -0
  237. package/src/api/resources/moduleSettings.ts +25 -0
  238. package/src/api/resources/planItems.ts +5 -5
  239. package/src/api/resources/publicConfig.ts +3 -3
  240. package/src/api/resources/sampleData.ts +2 -0
  241. package/src/api/resources/session.ts +59 -0
  242. package/src/api/resources/workspace.ts +2 -3
  243. package/src/app/KelpieApp.tsx +11 -3
  244. package/src/components/ActivitiesPanel.tsx +2 -0
  245. package/src/components/AddressesField.tsx +502 -0
  246. package/src/components/CountrySearch.tsx +71 -0
  247. package/src/components/EnvironmentBanner.tsx +23 -6
  248. package/src/components/InlineEdit.tsx +35 -1
  249. package/src/components/LinkedEventsPanel.tsx +50 -0
  250. package/src/components/NotesPanel.tsx +132 -15
  251. package/src/components/PlanAttention.tsx +5 -4
  252. package/src/components/PlanPanel.tsx +3 -3
  253. package/src/components/Shell.tsx +16 -1
  254. package/src/components/SocialNetworkIcon.tsx +88 -0
  255. package/src/components/SocialProfilesField.tsx +57 -10
  256. package/src/components/TimezoneSearch.tsx +67 -0
  257. package/src/components/WorkspaceSwitcher.tsx +136 -0
  258. package/src/components/environmentTone.ts +40 -0
  259. package/src/index.ts +42 -3
  260. package/src/lib/timezones.ts +43 -0
  261. package/src/pages/CompaniesPage.tsx +14 -13
  262. package/src/pages/CompanyDetail.tsx +59 -27
  263. package/src/pages/DealDetail.tsx +5 -0
  264. package/src/pages/DecisionsPage.tsx +7 -0
  265. package/src/pages/EnquiryDetail.tsx +7 -0
  266. package/src/pages/EventDetail.tsx +794 -0
  267. package/src/pages/EventsPage.tsx +382 -0
  268. package/src/pages/ListDetail.tsx +24 -0
  269. package/src/pages/OpportunityDetail.tsx +7 -0
  270. package/src/pages/PartnershipDetail.tsx +7 -0
  271. package/src/pages/PeoplePage.tsx +18 -5
  272. package/src/pages/PersonDetail.tsx +300 -100
  273. package/src/pages/PlanningPage.tsx +17 -14
  274. package/src/pages/RaiseDetail.tsx +5 -0
  275. package/src/pages/RoleDetail.tsx +8 -0
  276. package/src/pages/SearchPage.tsx +2 -1
  277. package/src/pages/account/PreferencesPage.tsx +2 -37
  278. package/src/pages/admin/DataPage.tsx +63 -21
  279. package/src/pages/admin/FieldsPage.tsx +1 -1
  280. package/src/pages/admin/ModulesPage.tsx +1 -0
  281. package/src/pages/admin/WorkspaceSettingsPage.tsx +10 -33
  282. package/src/pages/auth/AuthLayout.tsx +8 -4
  283. package/src/pages/auth/RegionSwitcher.tsx +56 -0
  284. package/src/pages/auth/SignInPage.tsx +10 -6
  285. package/src/pages/auth/SignUpPage.tsx +25 -0
  286. package/src/pages/auth/regionHref.ts +10 -0
  287. package/src/pages/dashboard/DashboardPage.tsx +16 -48
  288. package/src/pages/dashboard/attention.ts +24 -78
  289. package/src/pages/dashboard/sections.tsx +8 -2
  290. package/src/pages/forms/FormActions.tsx +14 -8
  291. package/src/pages/onboarding/HandbookStepPage.tsx +21 -24
  292. package/src/pages/onboarding/InvitesStepPage.tsx +27 -19
  293. package/src/pages/onboarding/ModulesStepPage.tsx +145 -0
  294. package/src/pages/onboarding/OnboardingNav.tsx +97 -0
  295. package/src/pages/onboarding/OrganisationStepPage.tsx +130 -0
  296. package/src/pages/onboarding/WorkspaceStepPage.tsx +53 -38
  297. package/src/pages/onboarding/handbookTemplates.ts +77 -0
  298. package/src/pages/onboarding/onboardingModules.ts +202 -0
  299. package/src/pages/onboarding/onboardingRerun.ts +41 -0
  300. package/src/styles.css +30 -0
@@ -0,0 +1,502 @@
1
+ import { formatAddress, primaryAddress } from '@kelpie/schemas'
2
+ import type { PostalAddress } from '@kelpie/schemas'
3
+ import { useEffect, useId, useMemo, useRef, useState } from 'react'
4
+
5
+ import { Chip } from './Chip.tsx'
6
+ import { CountrySearch } from './CountrySearch.tsx'
7
+ import { SidebarField } from './SidebarField.tsx'
8
+
9
+ export interface AddressesFieldProps<Kind extends string> {
10
+ readonly value: readonly PostalAddress<Kind>[]
11
+ readonly kinds: readonly Kind[]
12
+ readonly kindLabels: Readonly<Record<Kind, string>>
13
+ readonly onChange: (addresses: readonly PostalAddress<Kind>[]) => void
14
+ }
15
+
16
+ export interface AddressSidebarLinkProps {
17
+ readonly addresses: readonly PostalAddress[]
18
+ readonly onOpen: () => void
19
+ }
20
+
21
+ /**
22
+ * The primary address as a single sidebar link. Clicking it opens the Addresses
23
+ * tab; the editor itself does not live in the aside.
24
+ */
25
+ export function AddressSidebarLink({
26
+ addresses,
27
+ onOpen,
28
+ }: AddressSidebarLinkProps): React.JSX.Element {
29
+ const address = primaryAddress(addresses)
30
+ const text = address === undefined ? 'Add address…' : formatAddress(address)
31
+
32
+ return (
33
+ <SidebarField label="Address">
34
+ <button
35
+ type="button"
36
+ onClick={onOpen}
37
+ className="text-left text-[12px] font-medium text-accent transition hover:text-accent-hover hover:underline"
38
+ >
39
+ {text}
40
+ </button>
41
+ </SidebarField>
42
+ )
43
+ }
44
+
45
+ interface AddressDraft {
46
+ readonly line1: string
47
+ readonly line2: string
48
+ readonly city: string
49
+ readonly region: string
50
+ readonly postalCode: string
51
+ readonly country: string
52
+ }
53
+
54
+ function emptyDraft(): AddressDraft {
55
+ return { line1: '', line2: '', city: '', region: '', postalCode: '', country: '' }
56
+ }
57
+
58
+ function fromAddress(address: PostalAddress): AddressDraft {
59
+ return {
60
+ line1: address.line1 ?? '',
61
+ line2: address.line2 ?? '',
62
+ city: address.city ?? '',
63
+ region: address.region ?? '',
64
+ postalCode: address.postalCode ?? '',
65
+ country: address.country ?? '',
66
+ }
67
+ }
68
+
69
+ function blankToNull(value: string): string | null {
70
+ const trimmed = value.trim()
71
+
72
+ return trimmed.length === 0 ? null : trimmed
73
+ }
74
+
75
+ function partsFromDraft(draft: AddressDraft): {
76
+ readonly line1: string | null
77
+ readonly line2: string | null
78
+ readonly city: string | null
79
+ readonly region: string | null
80
+ readonly postalCode: string | null
81
+ readonly country: string | null
82
+ } {
83
+ return {
84
+ line1: blankToNull(draft.line1),
85
+ line2: blankToNull(draft.line2),
86
+ city: blankToNull(draft.city),
87
+ region: blankToNull(draft.region),
88
+ postalCode: blankToNull(draft.postalCode),
89
+ country: blankToNull(draft.country),
90
+ }
91
+ }
92
+
93
+ function draftHasLocation(draft: AddressDraft): boolean {
94
+ return Object.values(partsFromDraft(draft)).some((part) => part !== null)
95
+ }
96
+
97
+ function withPrimary<Kind extends string>(
98
+ addresses: readonly PostalAddress<Kind>[],
99
+ kind: Kind,
100
+ ): readonly PostalAddress<Kind>[] {
101
+ return addresses.map((address) => ({ ...address, primary: address.kind === kind }))
102
+ }
103
+
104
+ function withoutKind<Kind extends string>(
105
+ addresses: readonly PostalAddress<Kind>[],
106
+ kind: Kind,
107
+ ): readonly PostalAddress<Kind>[] {
108
+ const next = addresses.filter((address) => address.kind !== kind)
109
+
110
+ if (next.length === 0 || next.some((address) => address.primary)) {
111
+ return next
112
+ }
113
+
114
+ return next.map((address, index) => ({ ...address, primary: index === 0 }))
115
+ }
116
+
117
+ function AddressPartInputs({
118
+ draft,
119
+ onChange,
120
+ }: {
121
+ readonly draft: AddressDraft
122
+ readonly onChange: (draft: AddressDraft) => void
123
+ }): React.JSX.Element {
124
+ const inputClass =
125
+ 'w-full rounded-md border border-border bg-surface-raised px-2 py-1 text-[12px] outline-none focus:border-accent focus:ring-2 focus:ring-accent/20'
126
+
127
+ function set(field: keyof AddressDraft, value: string): void {
128
+ onChange({ ...draft, [field]: value })
129
+ }
130
+
131
+ return (
132
+ <div className="space-y-1.5">
133
+ <input
134
+ value={draft.line1}
135
+ onChange={(event) => {
136
+ set('line1', event.target.value)
137
+ }}
138
+ placeholder="Line 1"
139
+ className={inputClass}
140
+ />
141
+ <input
142
+ value={draft.line2}
143
+ onChange={(event) => {
144
+ set('line2', event.target.value)
145
+ }}
146
+ placeholder="Line 2"
147
+ className={inputClass}
148
+ />
149
+ <input
150
+ value={draft.city}
151
+ onChange={(event) => {
152
+ set('city', event.target.value)
153
+ }}
154
+ placeholder="City"
155
+ className={inputClass}
156
+ />
157
+ <div className="grid grid-cols-2 gap-1.5">
158
+ <input
159
+ value={draft.region}
160
+ onChange={(event) => {
161
+ set('region', event.target.value)
162
+ }}
163
+ placeholder="Region"
164
+ className={inputClass}
165
+ />
166
+ <input
167
+ value={draft.postalCode}
168
+ onChange={(event) => {
169
+ set('postalCode', event.target.value)
170
+ }}
171
+ placeholder="Postal code"
172
+ className={inputClass}
173
+ />
174
+ </div>
175
+ <CountrySearch
176
+ value={draft.country}
177
+ onChange={(country) => {
178
+ set('country', country)
179
+ }}
180
+ />
181
+ </div>
182
+ )
183
+ }
184
+
185
+ /**
186
+ * Add, edit, and remove labelled postal addresses. One entry per kind. The first
187
+ * added address is primary; marking another one primary clears the old mark;
188
+ * removing the primary promotes the first remaining entry.
189
+ */
190
+ export function AddressesField<Kind extends string>({
191
+ value,
192
+ kinds,
193
+ kindLabels,
194
+ onChange,
195
+ }: AddressesFieldProps<Kind>): React.JSX.Element {
196
+ const listId = useId()
197
+ const rootRef = useRef<HTMLDivElement>(null)
198
+ const [open, setOpen] = useState(false)
199
+ const [query, setQuery] = useState('')
200
+ const [highlight, setHighlight] = useState(0)
201
+ const [pendingKind, setPendingKind] = useState<Kind | null>(null)
202
+ const [pendingDraft, setPendingDraft] = useState<AddressDraft>(emptyDraft)
203
+ const [editingKind, setEditingKind] = useState<Kind | null>(null)
204
+ const [editDraft, setEditDraft] = useState<AddressDraft>(emptyDraft)
205
+ const [editPrimary, setEditPrimary] = useState(false)
206
+
207
+ const available = useMemo(() => {
208
+ const used = new Set(value.map((address) => address.kind))
209
+
210
+ return kinds.filter((kind) => !used.has(kind))
211
+ }, [kinds, value])
212
+
213
+ const filtered = useMemo(() => {
214
+ const term = query.trim().toLowerCase()
215
+
216
+ return term.length === 0
217
+ ? available
218
+ : available.filter((kind) => kindLabels[kind].toLowerCase().includes(term))
219
+ }, [available, kindLabels, query])
220
+
221
+ useEffect(() => {
222
+ if (!open) {
223
+ return
224
+ }
225
+
226
+ function onPointerDown(event: MouseEvent): void {
227
+ if (rootRef.current?.contains(event.target as Node) !== true) {
228
+ setOpen(false)
229
+ setQuery('')
230
+ }
231
+ }
232
+
233
+ document.addEventListener('mousedown', onPointerDown)
234
+
235
+ return () => {
236
+ document.removeEventListener('mousedown', onPointerDown)
237
+ }
238
+ }, [open])
239
+
240
+ useEffect(() => {
241
+ setHighlight(0)
242
+ }, [query, open])
243
+
244
+ function pickKind(kind: Kind): void {
245
+ setEditingKind(null)
246
+ setPendingKind(kind)
247
+ setPendingDraft(emptyDraft())
248
+ setOpen(false)
249
+ setQuery('')
250
+ }
251
+
252
+ function cancelPending(): void {
253
+ setPendingKind(null)
254
+ setPendingDraft(emptyDraft())
255
+ }
256
+
257
+ function commitPending(): void {
258
+ if (pendingKind === null || !draftHasLocation(pendingDraft)) {
259
+ return
260
+ }
261
+
262
+ const next: PostalAddress<Kind> = {
263
+ kind: pendingKind,
264
+ ...partsFromDraft(pendingDraft),
265
+ primary: value.length === 0,
266
+ }
267
+
268
+ onChange(value.length === 0 ? [next] : [...value.filter((address) => address.kind !== pendingKind), next])
269
+ cancelPending()
270
+ }
271
+
272
+ function cancelEdit(): void {
273
+ setEditingKind(null)
274
+ setEditDraft(emptyDraft())
275
+ setEditPrimary(false)
276
+ }
277
+
278
+ function commitEdit(): void {
279
+ if (editingKind === null) {
280
+ return
281
+ }
282
+
283
+ if (!draftHasLocation(editDraft)) {
284
+ onChange(withoutKind(value, editingKind))
285
+ cancelEdit()
286
+ return
287
+ }
288
+
289
+ const next = value.map((address) =>
290
+ address.kind === editingKind
291
+ ? { ...address, ...partsFromDraft(editDraft) }
292
+ : address,
293
+ )
294
+
295
+ onChange(editPrimary ? withPrimary(next, editingKind) : next)
296
+ cancelEdit()
297
+ }
298
+
299
+ return (
300
+ <div className="space-y-0.5">
301
+ <ul>
302
+ {value.map((address) => {
303
+ const label = kindLabels[address.kind]
304
+
305
+ if (editingKind === address.kind) {
306
+ return (
307
+ <li key={address.kind} className="py-1">
308
+ <div className="mb-1 flex items-center gap-1.5">
309
+ <span className="text-[11px] font-medium text-ink-muted">{label}</span>
310
+ {editPrimary && <Chip tone="accent">Primary</Chip>}
311
+ </div>
312
+ <AddressPartInputs draft={editDraft} onChange={setEditDraft} />
313
+ <div className="mt-1.5 flex items-center justify-end gap-1.5">
314
+ {!editPrimary && (
315
+ <button
316
+ type="button"
317
+ onClick={() => {
318
+ setEditPrimary(true)
319
+ }}
320
+ className="mr-auto rounded-md px-2 py-1 text-[11px] font-medium text-ink-muted hover:text-ink"
321
+ >
322
+ Make Primary
323
+ </button>
324
+ )}
325
+ <button
326
+ type="button"
327
+ onClick={cancelEdit}
328
+ className="rounded-md px-2 py-1 text-[11px] font-medium text-ink-muted hover:text-ink"
329
+ >
330
+ Cancel
331
+ </button>
332
+ <button
333
+ type="button"
334
+ onClick={commitEdit}
335
+ className="rounded-md bg-accent px-2.5 py-1 text-[11px] font-semibold text-accent-fg hover:bg-accent-hover"
336
+ >
337
+ Save
338
+ </button>
339
+ </div>
340
+ </li>
341
+ )
342
+ }
343
+
344
+ const summary = formatAddress(address)
345
+
346
+ return (
347
+ <li
348
+ key={address.kind}
349
+ className="-mx-1 rounded-md px-1 py-1 hover:bg-surface"
350
+ >
351
+ <div className="flex items-start gap-1">
352
+ <div className="min-w-0 flex-1">
353
+ <div className="flex items-center gap-1.5 text-[12px] leading-snug text-ink">
354
+ <span className="font-medium">{label}</span>
355
+ {address.primary && <Chip tone="accent">Primary</Chip>}
356
+ </div>
357
+ {summary.length > 0 && (
358
+ <div className="text-[12px] leading-snug text-ink-muted">{summary}</div>
359
+ )}
360
+ </div>
361
+ <div className="flex shrink-0 items-center">
362
+ <button
363
+ type="button"
364
+ onClick={() => {
365
+ cancelPending()
366
+ setEditingKind(address.kind)
367
+ setEditDraft(fromAddress(address))
368
+ setEditPrimary(address.primary)
369
+ }}
370
+ className="rounded px-1.5 py-0.5 text-[11px] font-medium text-ink-faint hover:bg-surface-raised hover:text-ink"
371
+ >
372
+ Edit
373
+ </button>
374
+ <button
375
+ type="button"
376
+ onClick={() => {
377
+ if (editingKind === address.kind) {
378
+ cancelEdit()
379
+ }
380
+
381
+ onChange(withoutKind(value, address.kind))
382
+ }}
383
+ className="rounded px-1 py-0.5 text-[13px] leading-none text-ink-faint hover:bg-danger-soft hover:text-danger"
384
+ aria-label={`Remove ${label}`}
385
+ >
386
+ ×
387
+ </button>
388
+ </div>
389
+ </div>
390
+ </li>
391
+ )
392
+ })}
393
+ </ul>
394
+
395
+ {value.length === 0 && pendingKind === null && (
396
+ <p className="py-0.5 text-[12px] text-ink-faint">None yet</p>
397
+ )}
398
+
399
+ {pendingKind !== null ? (
400
+ <div className="mt-1 space-y-1.5 rounded-md border border-border bg-surface px-2 py-2">
401
+ <div className="text-[11px] font-medium text-ink">{kindLabels[pendingKind]}</div>
402
+ <AddressPartInputs draft={pendingDraft} onChange={setPendingDraft} />
403
+ <div className="flex justify-end gap-1.5">
404
+ <button
405
+ type="button"
406
+ onClick={cancelPending}
407
+ className="rounded-md px-2 py-1 text-[11px] font-medium text-ink-muted hover:text-ink"
408
+ >
409
+ Cancel
410
+ </button>
411
+ <button
412
+ type="button"
413
+ onClick={commitPending}
414
+ disabled={!draftHasLocation(pendingDraft)}
415
+ className="rounded-md bg-accent px-2.5 py-1 text-[11px] font-semibold text-accent-fg hover:bg-accent-hover disabled:opacity-40"
416
+ >
417
+ Add
418
+ </button>
419
+ </div>
420
+ </div>
421
+ ) : (
422
+ available.length > 0 && (
423
+ <div ref={rootRef} className="relative">
424
+ <button
425
+ type="button"
426
+ onClick={() => {
427
+ setOpen((current) => !current)
428
+ setQuery('')
429
+ }}
430
+ className="mt-0.5 -mx-1 rounded-md px-1 py-1 text-[12px] font-medium text-ink-muted transition hover:bg-surface hover:text-accent"
431
+ >
432
+ + Add
433
+ </button>
434
+
435
+ {open && (
436
+ <div className="absolute z-20 mt-0.5 w-full overflow-hidden rounded-md border border-border bg-surface-raised">
437
+ <input
438
+ value={query}
439
+ onChange={(event) => {
440
+ setQuery(event.target.value)
441
+ }}
442
+ onKeyDown={(event) => {
443
+ if (event.key === 'ArrowDown') {
444
+ event.preventDefault()
445
+ setHighlight((current) =>
446
+ Math.min(current + 1, Math.max(filtered.length - 1, 0)),
447
+ )
448
+ } else if (event.key === 'ArrowUp') {
449
+ event.preventDefault()
450
+ setHighlight((current) => Math.max(current - 1, 0))
451
+ } else if (event.key === 'Enter') {
452
+ event.preventDefault()
453
+ const hit = filtered[highlight]
454
+
455
+ if (hit !== undefined) {
456
+ pickKind(hit)
457
+ }
458
+ } else if (event.key === 'Escape') {
459
+ setOpen(false)
460
+ setQuery('')
461
+ }
462
+ }}
463
+ placeholder="Search kinds…"
464
+ role="combobox"
465
+ aria-expanded={open}
466
+ aria-controls={listId}
467
+ aria-autocomplete="list"
468
+ className="w-full border-b border-border px-2.5 py-1.5 text-[12px] outline-none focus:bg-accent-soft/30"
469
+ />
470
+ <ul id={listId} role="listbox" className="max-h-48 overflow-auto py-1">
471
+ {filtered.length === 0 ? (
472
+ <li className="px-3 py-2 text-[12px] text-ink-faint">No kinds match</li>
473
+ ) : (
474
+ filtered.map((kind, index) => (
475
+ <li key={kind} role="option" aria-selected={index === highlight}>
476
+ <button
477
+ type="button"
478
+ onMouseEnter={() => {
479
+ setHighlight(index)
480
+ }}
481
+ onClick={() => {
482
+ pickKind(kind)
483
+ }}
484
+ className={[
485
+ 'flex w-full px-3 py-1.5 text-left text-[12px] text-ink',
486
+ index === highlight ? 'bg-accent-soft' : 'hover:bg-surface',
487
+ ].join(' ')}
488
+ >
489
+ {kindLabels[kind]}
490
+ </button>
491
+ </li>
492
+ ))
493
+ )}
494
+ </ul>
495
+ </div>
496
+ )}
497
+ </div>
498
+ )
499
+ )}
500
+ </div>
501
+ )
502
+ }
@@ -0,0 +1,71 @@
1
+ import { COUNTRIES, countryName } from '@kelpie/schemas'
2
+ import type { JSX } from 'react'
3
+
4
+ import { EntitySearch } from './EntitySearch.tsx'
5
+
6
+ export interface CountrySearchProps {
7
+ readonly value: string
8
+ readonly onChange: (country: string) => void
9
+ readonly disabled?: boolean | undefined
10
+ readonly size?: 'sm' | 'md' | undefined
11
+ readonly className?: string | undefined
12
+ }
13
+
14
+ function countryOptions(
15
+ current: string,
16
+ ): readonly { readonly id: string; readonly label: string; readonly meta?: string }[] {
17
+ const options = COUNTRIES.map((country) => ({
18
+ id: country.code,
19
+ label: `${country.name} (${country.code})`,
20
+ ...(country.aliases === undefined ? {} : { meta: country.aliases.join(', ') }),
21
+ }))
22
+
23
+ if (current.length === 0 || COUNTRIES.some((country) => country.code === current)) {
24
+ return options
25
+ }
26
+
27
+ const name = countryName(current) ?? current
28
+
29
+ return [{ id: current, label: `${name} (${current})` }, ...options]
30
+ }
31
+
32
+ /**
33
+ * Searchable ISO 3166-1 alpha-2 picker.
34
+ *
35
+ * Accepts a stored code. The list searches English names, codes, and aliases.
36
+ * Clearing writes an empty string so the parent can store null.
37
+ */
38
+ export function CountrySearch({
39
+ value,
40
+ onChange,
41
+ disabled,
42
+ size = 'sm',
43
+ className = '',
44
+ }: CountrySearchProps): JSX.Element {
45
+ if (disabled === true) {
46
+ const padding = size === 'md' ? 'px-2.5 py-1.5 text-[13px]' : 'px-2 py-1.5 text-[12px]'
47
+ const label =
48
+ value.length === 0 ? '—' : `${countryName(value) ?? value}${value.length === 2 ? ` (${value})` : ''}`
49
+
50
+ return (
51
+ <div
52
+ className={`w-full rounded-md border border-border bg-surface-raised text-ink opacity-60 ${padding} ${className}`}
53
+ >
54
+ {label}
55
+ </div>
56
+ )
57
+ }
58
+
59
+ return (
60
+ <EntitySearch
61
+ options={countryOptions(value)}
62
+ value={value}
63
+ onChange={onChange}
64
+ placeholder="Search countries…"
65
+ emptyMessage="No matching countries"
66
+ limit={20}
67
+ size={size}
68
+ className={className}
69
+ />
70
+ )
71
+ }
@@ -1,18 +1,29 @@
1
1
  import { usePublicConfig } from '../api/resources/publicConfig.ts'
2
+ import { environmentTone, type EnvironmentTone } from './environmentTone.ts'
2
3
 
3
4
  /**
4
- * A thin strip above the app that names the site.
5
+ * A thin strip above the app that names the site and paints it in a colour
6
+ * that belongs to that name.
5
7
  *
6
8
  * Renders nothing on production, whether or not a site name was set: the
7
- * banner exists to tell dev, demo, and any other non-production install
8
- * apart. Renders nothing while the metadata is loading or on error either,
9
- * so a slow config request never pushes the app down half a second after
10
- * boot.
9
+ * banner exists to tell dev, demo, cloud, and any other non-production
10
+ * install apart. Colour does the first half of that work; the label does
11
+ * the rest. Renders nothing while the metadata is loading or on error
12
+ * either, so a slow config request never pushes the app down half a
13
+ * second after boot.
11
14
  *
12
15
  * Mounted at the top of `KelpieApp`, above the router, so it shows on the
13
16
  * sign-in and onboarding pages as well as the main app.
14
17
  */
15
18
 
19
+ const TONE_CLASSES: Readonly<Record<EnvironmentTone, string>> = {
20
+ dev: 'bg-env-dev text-env-dev-fg',
21
+ demo: 'bg-env-demo text-env-demo-fg',
22
+ staging: 'bg-env-staging text-env-staging-fg',
23
+ test: 'bg-env-test text-env-test-fg',
24
+ cloud: 'bg-env-cloud text-env-cloud-fg',
25
+ }
26
+
16
27
  export function EnvironmentBanner(): React.JSX.Element | null {
17
28
  const { config } = usePublicConfig()
18
29
 
@@ -21,9 +32,15 @@ export function EnvironmentBanner(): React.JSX.Element | null {
21
32
  }
22
33
 
23
34
  const label = config.siteName ?? config.runtimeMode
35
+ const tone = environmentTone(label)
36
+
37
+ const className = [
38
+ 'flex h-5 shrink-0 items-center justify-center px-2 text-[11px] font-medium tracking-wide uppercase',
39
+ TONE_CLASSES[tone],
40
+ ].join(' ')
24
41
 
25
42
  return (
26
- <div className="flex h-5 shrink-0 items-center justify-center bg-accent px-2 text-[11px] font-medium tracking-wide text-accent-fg uppercase">
43
+ <div data-tone={tone} className={className}>
27
44
  {label}
28
45
  </div>
29
46
  )
@@ -22,7 +22,12 @@ interface TextProps extends CommonProps {
22
22
  readonly value: string
23
23
  readonly onChange: (value: string) => void
24
24
  readonly multiline?: boolean
25
- readonly type?: 'text' | 'number' | 'date' | 'url' | 'email'
25
+ readonly type?: 'text' | 'number' | 'date' | 'datetime-local' | 'url' | 'email'
26
+ /**
27
+ * When the value is set, hover shows Visit (opens the address) and Edit
28
+ * instead of making the whole value a click-to-edit control.
29
+ */
30
+ readonly visitable?: boolean
26
31
  readonly options?: never
27
32
  }
28
33
 
@@ -32,10 +37,18 @@ interface SelectProps extends CommonProps {
32
37
  readonly options: readonly { readonly value: string; readonly label: string }[]
33
38
  readonly multiline?: never
34
39
  readonly type?: never
40
+ readonly visitable?: never
35
41
  }
36
42
 
37
43
  export type InlineEditProps = TextProps | SelectProps
38
44
 
45
+ const ACTION_CLASS =
46
+ 'rounded-md px-2 py-0.5 text-[11px] font-medium text-ink-muted hover:bg-surface-sunken hover:text-ink'
47
+
48
+ function hrefForVisit(value: string): string {
49
+ return /^https?:\/\//iu.test(value) ? value : `https://${value}`
50
+ }
51
+
39
52
  export function InlineEdit(props: InlineEditProps): React.JSX.Element {
40
53
  const {
41
54
  value,
@@ -158,6 +171,27 @@ export function InlineEdit(props: InlineEditProps): React.JSX.Element {
158
171
 
159
172
  const isEmpty = value.length === 0 || value === '—'
160
173
 
174
+ if (props.options === undefined && props.visitable === true && !isEmpty) {
175
+ return (
176
+ <div className={`group relative inline-flex max-w-full items-baseline ${className}`.trim()}>
177
+ <span className={`text-[13px] leading-snug ${displayClassName}`.trim()}>{display ?? value}</span>
178
+ <span className="absolute left-full top-1/2 flex -translate-y-1/2 gap-0.5 pl-1 whitespace-nowrap opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100">
179
+ <a
180
+ href={hrefForVisit(value)}
181
+ target="_blank"
182
+ rel="noopener noreferrer"
183
+ className={`${ACTION_CLASS} hover:text-accent`}
184
+ >
185
+ Visit
186
+ </a>
187
+ <button type="button" onClick={() => setEditing(true)} className={ACTION_CLASS}>
188
+ Edit
189
+ </button>
190
+ </span>
191
+ </div>
192
+ )
193
+ }
194
+
161
195
  return (
162
196
  <button
163
197
  type="button"