@payez/next-mvp 4.8.1 → 4.8.3

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 (314) hide show
  1. package/POST-INSTALL.md +408 -408
  2. package/README.md +859 -859
  3. package/dist/api-handlers/admin/audit.js +13 -21
  4. package/dist/api-handlers/admin/sessions.js +59 -45
  5. package/dist/api-handlers/admin/stats.js +1 -1
  6. package/dist/api-handlers/admin/users.js +15 -23
  7. package/dist/api-handlers/admin/vibe-data.d.ts +5 -0
  8. package/dist/api-handlers/admin/vibe-data.js +5 -0
  9. package/dist/components/account/MobileNavDrawer.js +18 -18
  10. package/dist/components/account/UserAvatarMenu.js +1 -1
  11. package/dist/components/admin/AlertSettingsTab.js +40 -40
  12. package/dist/components/admin/LoggingSettingsTab.js +28 -28
  13. package/dist/lib/session-store.js +21 -21
  14. package/dist/lib/startup-init.js +19 -19
  15. package/dist/pages/login/page.js +28 -28
  16. package/dist/pages/settings/EnhancedSettingsPage.js +17 -17
  17. package/dist/vibe/client.d.ts +1 -1
  18. package/dist/vibe/client.js +2 -1
  19. package/dist/vibe/filters.d.ts +36 -0
  20. package/dist/vibe/filters.js +47 -0
  21. package/dist/vibe/generic.js +2 -1
  22. package/dist/vibe/sessions.js +28 -3
  23. package/package.json +2 -2
  24. package/scripts/check-publish-eol.mjs +33 -15
  25. package/src/api/auth-handler.ts +550 -550
  26. package/src/api/index.ts +17 -17
  27. package/src/api-handlers/account/change-password.ts +170 -170
  28. package/src/api-handlers/account/masked-info.ts +46 -46
  29. package/src/api-handlers/account/profile.ts +99 -99
  30. package/src/api-handlers/account/recovery/forward.ts +117 -117
  31. package/src/api-handlers/account/recovery/initiate.ts +59 -59
  32. package/src/api-handlers/account/recovery/send-code.ts +21 -21
  33. package/src/api-handlers/account/recovery/verify-code.ts +20 -20
  34. package/src/api-handlers/account/reset-password.ts +87 -87
  35. package/src/api-handlers/account/send-code.ts +70 -70
  36. package/src/api-handlers/account/update-phone.ts +84 -84
  37. package/src/api-handlers/account/validate-password.ts +139 -139
  38. package/src/api-handlers/account/verify-email.ts +134 -134
  39. package/src/api-handlers/account/verify-sms.ts +134 -134
  40. package/src/api-handlers/admin/analytics.ts +452 -452
  41. package/src/api-handlers/admin/audit.ts +223 -232
  42. package/src/api-handlers/admin/index.ts +63 -63
  43. package/src/api-handlers/admin/redis-sessions.ts +259 -259
  44. package/src/api-handlers/admin/sessions.ts +341 -327
  45. package/src/api-handlers/admin/site-logs.ts +388 -388
  46. package/src/api-handlers/admin/stats.ts +260 -260
  47. package/src/api-handlers/admin/users.ts +240 -251
  48. package/src/api-handlers/admin/vibe-data.ts +338 -333
  49. package/src/api-handlers/anon/preferences.ts +123 -123
  50. package/src/api-handlers/auth/credential-login-email-code.ts +92 -92
  51. package/src/api-handlers/auth/credential-login.ts +948 -948
  52. package/src/api-handlers/auth/jwks.ts +31 -31
  53. package/src/api-handlers/auth/login.ts +252 -252
  54. package/src/api-handlers/auth/passkey-signin.ts +116 -116
  55. package/src/api-handlers/auth/refresh.ts +829 -829
  56. package/src/api-handlers/auth/signout.ts +201 -201
  57. package/src/api-handlers/auth/status.ts +23 -23
  58. package/src/api-handlers/auth/update-session.ts +144 -144
  59. package/src/api-handlers/auth/validate.ts +45 -45
  60. package/src/api-handlers/auth/verify-code.ts +116 -116
  61. package/src/api-handlers/mfa/backup-code-verify.ts +170 -170
  62. package/src/api-handlers/mfa/backup-codes.ts +42 -42
  63. package/src/api-handlers/mfa/methods.ts +47 -47
  64. package/src/api-handlers/mfa/totp-confirm.ts +43 -43
  65. package/src/api-handlers/mfa/totp-enroll.ts +43 -43
  66. package/src/api-handlers/mfa/totp-verify.ts +172 -172
  67. package/src/api-handlers/oauth/resume.ts +182 -182
  68. package/src/api-handlers/onboarding/send-email-code.ts +18 -18
  69. package/src/api-handlers/onboarding/set-password.ts +21 -21
  70. package/src/api-handlers/onboarding/verify-code.ts +20 -20
  71. package/src/api-handlers/onboarding/verify-welcome-email.ts +17 -17
  72. package/src/api-handlers/passkeys/list.ts +32 -32
  73. package/src/api-handlers/passkeys/manage.ts +55 -55
  74. package/src/api-handlers/passkeys/register-options.ts +43 -43
  75. package/src/api-handlers/passkeys/register-verify.ts +42 -42
  76. package/src/api-handlers/session/refresh-viability.ts +56 -56
  77. package/src/api-handlers/session/viability.ts +238 -238
  78. package/src/api-handlers/test/force-expire.ts +167 -167
  79. package/src/auth/auth-decision.ts +292 -292
  80. package/src/auth/better-auth.ts +1688 -1688
  81. package/src/auth/idp-oauth.ts +310 -310
  82. package/src/auth/password-reset-bridge.ts +98 -98
  83. package/src/auth/revocation-fields.ts +16 -16
  84. package/src/auth/route-config.ts +236 -236
  85. package/src/auth/safe-callback.ts +199 -199
  86. package/src/auth/session-cookie-clear.ts +30 -30
  87. package/src/auth/session-reset-link.ts +159 -159
  88. package/src/auth/session-response-sanitizer.ts +151 -151
  89. package/src/auth/types/auth-types.ts +555 -555
  90. package/src/auth/types/index.ts +7 -7
  91. package/src/auth/unauthenticated-routes.ts +2 -2
  92. package/src/auth/utils/idp-client.ts +364 -364
  93. package/src/auth/utils/index.ts +6 -6
  94. package/src/auth/utils/token-utils.ts +243 -243
  95. package/src/client/AuthContext.tsx +192 -192
  96. package/src/client/better-auth-client.ts +70 -70
  97. package/src/client/fetch-with-auth.ts +47 -47
  98. package/src/client/fetchWithSession.ts +21 -21
  99. package/src/client/index.ts +13 -13
  100. package/src/client/useAnonSession.ts +131 -131
  101. package/src/components/SessionSync.tsx +138 -138
  102. package/src/components/SignalRHealthCheck.tsx +131 -131
  103. package/src/components/account/MobileNavDrawer.tsx +305 -305
  104. package/src/components/account/UserAvatarMenu.tsx +277 -277
  105. package/src/components/account/index.ts +13 -13
  106. package/src/components/admin/AlertSettingsTab.tsx +728 -728
  107. package/src/components/admin/AnalyticsTab.tsx +703 -703
  108. package/src/components/admin/DataBrowserTab.tsx +508 -508
  109. package/src/components/admin/LoggingSettingsTab.tsx +665 -665
  110. package/src/components/admin/SessionsTab.tsx +421 -421
  111. package/src/components/admin/StatsTab.tsx +379 -379
  112. package/src/components/admin/VibeAdminContext.tsx +87 -87
  113. package/src/components/admin/VibeAdminLayout.tsx +187 -187
  114. package/src/components/admin/index.ts +59 -59
  115. package/src/components/auth/CredentialSignInForm.tsx +240 -240
  116. package/src/components/auth/FederatedAuthSection.tsx +129 -129
  117. package/src/components/auth/ModeAwareLoginPage.tsx +146 -146
  118. package/src/components/auth/ModeAwareSignupPage.tsx +200 -200
  119. package/src/components/auth/PasswordResetBridge.tsx +30 -30
  120. package/src/components/auth/TraditionalAuthSection.tsx +103 -103
  121. package/src/components/recovery/CompleteStep.tsx +36 -36
  122. package/src/components/recovery/InitiateRecoveryStep.tsx +68 -68
  123. package/src/components/recovery/RequestedStep.tsx +30 -30
  124. package/src/components/recovery/SelectMethodStep.tsx +73 -73
  125. package/src/components/recovery/SetPasswordStep.tsx +118 -118
  126. package/src/components/recovery/VerifyCodeStep.tsx +90 -90
  127. package/src/components/reserved/ReservedRecoveryWarning.tsx +160 -160
  128. package/src/components/reserved/ReservedStatusBox.tsx +118 -118
  129. package/src/components/ui/BetaBadge.tsx +58 -58
  130. package/src/components/ui/Footer.tsx +93 -93
  131. package/src/config/env.ts +55 -55
  132. package/src/config/logger.ts +62 -62
  133. package/src/config/logging-config.ts +82 -82
  134. package/src/config/unauthenticated-routes.ts +19 -19
  135. package/src/config/vibe-log-transport.ts +264 -264
  136. package/src/edge/internal-api-url.ts +65 -65
  137. package/src/edge/middleware.ts +42 -42
  138. package/src/hooks/useAuth.ts +117 -117
  139. package/src/hooks/useAuthSettings.ts +109 -109
  140. package/src/hooks/useAvailableProviders.ts +143 -143
  141. package/src/hooks/usePasswordValidation.ts +127 -127
  142. package/src/hooks/useProfile.ts +82 -82
  143. package/src/hooks/usePublicAuthSettings.ts +171 -171
  144. package/src/hooks/useSessionExpiration.ts +101 -101
  145. package/src/hooks/useViabilitySession.ts +337 -337
  146. package/src/index.ts +64 -64
  147. package/src/lib/anon-session.ts +213 -213
  148. package/src/lib/api-handler.ts +643 -643
  149. package/src/lib/app-slug.ts +228 -228
  150. package/src/lib/authorize-resume.ts +172 -172
  151. package/src/lib/bearer-fetch.ts +31 -31
  152. package/src/lib/client-key.ts +88 -88
  153. package/src/lib/code-step-copy.ts +83 -83
  154. package/src/lib/credential-sign-in-client.ts +101 -101
  155. package/src/lib/demo-mode.ts +18 -18
  156. package/src/lib/device-id.ts +67 -67
  157. package/src/lib/ensure-fresh-access-token.ts +509 -509
  158. package/src/lib/geolocation.ts +265 -265
  159. package/src/lib/idp-client-config.ts +1005 -1005
  160. package/src/lib/idp-fetch.ts +169 -169
  161. package/src/lib/idp-url-guard.ts +37 -37
  162. package/src/lib/internal-api.ts +171 -171
  163. package/src/lib/jwt-decode-client.ts +45 -45
  164. package/src/lib/jwt-decode.ts +82 -82
  165. package/src/lib/lock-window.ts +49 -49
  166. package/src/lib/login-error-message.ts +41 -41
  167. package/src/lib/never-in-response.ts +64 -64
  168. package/src/lib/nextauth-secret.ts +168 -168
  169. package/src/lib/onboarding-proxy.ts +57 -57
  170. package/src/lib/password-rules.ts +50 -50
  171. package/src/lib/rate-limit-service.ts +9 -9
  172. package/src/lib/redis.ts +105 -105
  173. package/src/lib/refresh-token-validator.ts +64 -64
  174. package/src/lib/roles.ts +177 -177
  175. package/src/lib/safe-error-response.ts +149 -149
  176. package/src/lib/secret-validation.ts +8 -8
  177. package/src/lib/session-cookie-shape.ts +185 -185
  178. package/src/lib/session-store.ts +1092 -1092
  179. package/src/lib/session.ts +34 -34
  180. package/src/lib/single-flight.ts +64 -64
  181. package/src/lib/site-logger.ts +245 -245
  182. package/src/lib/standardized-client-api.ts +901 -901
  183. package/src/lib/startup-init.ts +276 -276
  184. package/src/lib/test-aware-get-token.ts +41 -41
  185. package/src/lib/token-expiry.ts +40 -40
  186. package/src/lib/token-lifecycle.ts +546 -546
  187. package/src/lib/token-log-id.ts +69 -69
  188. package/src/lib/twofa-rejection.ts +91 -91
  189. package/src/lib/types/api-responses.ts +336 -336
  190. package/src/lib/user-agent-parser.ts +252 -252
  191. package/src/logging/api/admin-analytics.ts +51 -51
  192. package/src/logging/api/audit-log.ts +53 -53
  193. package/src/logging/components/AdminAnalyticsLayout.tsx +49 -49
  194. package/src/logging/components/AuditLogViewer.tsx +125 -125
  195. package/src/logging/components/ErrorMetricsCard.tsx +98 -98
  196. package/src/logging/components/HealthMetricsCard.tsx +70 -70
  197. package/src/logging/hooks/useAdminAnalytics.ts +22 -22
  198. package/src/logging/hooks/useAuditLog.ts +24 -24
  199. package/src/logging/hooks/useErrorMetrics.ts +40 -40
  200. package/src/logging/hooks/useHealthMetrics.ts +44 -44
  201. package/src/logging/index.ts +18 -18
  202. package/src/logging/types/analytics.ts +81 -81
  203. package/src/logging/types/audit.ts +31 -31
  204. package/src/logging/types/index.ts +3 -3
  205. package/src/middleware/create-middleware.ts +1141 -1141
  206. package/src/middleware/rbac-check.ts +279 -279
  207. package/src/middleware/twofa-presets.ts +224 -224
  208. package/src/models/DecodedAccessToken.ts +16 -16
  209. package/src/models/SessionModel.ts +331 -331
  210. package/src/pages/admin-login/page.tsx +111 -111
  211. package/src/pages/admin-page-permissions/PagePermissionsAdminPage.tsx +527 -527
  212. package/src/pages/admin-page-permissions/index.ts +7 -7
  213. package/src/pages/admin-roles/RolesAdminPage.tsx +533 -533
  214. package/src/pages/admin-roles/index.ts +9 -9
  215. package/src/pages/admin-roles/modals.tsx +469 -469
  216. package/src/pages/client-admin/ClientSiteAdminPage.tsx +382 -382
  217. package/src/pages/client-admin/index.ts +33 -33
  218. package/src/pages/coming-soon/page.tsx +83 -83
  219. package/src/pages/login/page.tsx +338 -338
  220. package/src/pages/onboarding/index.ts +11 -11
  221. package/src/pages/onboarding/page.tsx +212 -212
  222. package/src/pages/profile/EnhancedProfilePage.tsx +514 -514
  223. package/src/pages/profile/index.ts +9 -9
  224. package/src/pages/profile/page.tsx +166 -166
  225. package/src/pages/recovery/page.tsx +267 -267
  226. package/src/pages/roles/MyRolesPage.tsx +211 -211
  227. package/src/pages/roles/components.tsx +294 -294
  228. package/src/pages/roles/index.ts +17 -17
  229. package/src/pages/security/EnhancedSecurityPage.tsx +660 -660
  230. package/src/pages/security/index.ts +12 -12
  231. package/src/pages/security/page.tsx +507 -507
  232. package/src/pages/service-unavailable/page.tsx +63 -63
  233. package/src/pages/settings/EnhancedSettingsPage.tsx +642 -642
  234. package/src/pages/settings/index.ts +9 -9
  235. package/src/pages/settings/page.tsx +47 -47
  236. package/src/pages/showcase/ShowcasePage.tsx +532 -532
  237. package/src/pages/showcase/index.ts +13 -13
  238. package/src/pages/test-env/EmergencyLogoutPage.tsx +192 -192
  239. package/src/pages/test-env/JwtInspectPage.tsx +449 -449
  240. package/src/pages/test-env/RefreshTokenPage.tsx +178 -178
  241. package/src/pages/test-env/TestEnvPage.tsx +130 -130
  242. package/src/pages/test-env/guarded.tsx +81 -81
  243. package/src/pages/test-env/index.ts +77 -77
  244. package/src/pages/test-env/redact-credentials.ts +96 -96
  245. package/src/pages/verify-code/page.tsx +746 -746
  246. package/src/routes/account/index.ts +31 -31
  247. package/src/routes/account/masked-info.ts +36 -36
  248. package/src/routes/account/send-code.ts +39 -39
  249. package/src/routes/account/update-phone.ts +13 -13
  250. package/src/routes/account/verify-email.ts +40 -40
  251. package/src/routes/account/verify-sms.ts +40 -40
  252. package/src/routes/auth/credential-login-email-code.ts +6 -6
  253. package/src/routes/auth/credential-login.ts +6 -6
  254. package/src/routes/auth/index.ts +22 -22
  255. package/src/routes/auth/logout.ts +111 -111
  256. package/src/routes/auth/nextauth.ts +45 -45
  257. package/src/routes/auth/passkey-signin.ts +13 -13
  258. package/src/routes/auth/refresh.ts +77 -77
  259. package/src/routes/auth/session-reset.ts +37 -37
  260. package/src/routes/auth/session.ts +261 -261
  261. package/src/routes/auth/settings.ts +181 -181
  262. package/src/routes/auth/viability.ts +519 -519
  263. package/src/routes/index.ts +17 -17
  264. package/src/routes/mfa/backup-code-verify.ts +1 -1
  265. package/src/routes/mfa/backup-codes.ts +10 -10
  266. package/src/routes/mfa/methods.ts +22 -22
  267. package/src/routes/mfa/totp-confirm.ts +10 -10
  268. package/src/routes/mfa/totp-enroll.ts +10 -10
  269. package/src/routes/mfa/totp-verify.ts +14 -14
  270. package/src/routes/onboarding/index.ts +15 -15
  271. package/src/routes/onboarding/send-email-code.ts +2 -2
  272. package/src/routes/onboarding/set-password.ts +2 -2
  273. package/src/routes/onboarding/verify-code.ts +2 -2
  274. package/src/routes/onboarding/verify-welcome-email.ts +2 -2
  275. package/src/routes/passkeys/list.ts +10 -10
  276. package/src/routes/passkeys/manage.ts +23 -23
  277. package/src/routes/passkeys/register-options.ts +10 -10
  278. package/src/routes/passkeys/register-verify.ts +10 -10
  279. package/src/routes/session/index.ts +7 -7
  280. package/src/routes/session/refresh-viability.ts +17 -17
  281. package/src/server/auth-guard.ts +203 -203
  282. package/src/server/auth.ts +486 -486
  283. package/src/server/decode-session.ts +309 -309
  284. package/src/server/slim-middleware.ts +117 -117
  285. package/src/server/verified-session-token.ts +103 -103
  286. package/src/server/with-auth.ts +93 -93
  287. package/src/services/signalrActivityService.ts +258 -258
  288. package/src/stores/authStore.ts +1900 -1900
  289. package/src/templates/instrumentation.ts +60 -60
  290. package/src/theme/ThemeProvider.tsx +39 -39
  291. package/src/theme/default.ts +33 -33
  292. package/src/theme/index.ts +31 -31
  293. package/src/theme/types.ts +69 -69
  294. package/src/theme/useTheme.ts +57 -57
  295. package/src/theme/utils.ts +40 -40
  296. package/src/types/api.ts +12 -12
  297. package/src/types/auth.d.ts +23 -23
  298. package/src/types/auth.ts +48 -48
  299. package/src/types/logging.ts +11 -11
  300. package/src/types/recovery.ts +54 -54
  301. package/src/utils/api.ts +401 -401
  302. package/src/utils/circuitBreaker.ts +40 -40
  303. package/src/utils/error-message.ts +108 -108
  304. package/src/utils/layout/reservedSpace.ts +124 -124
  305. package/src/utils/logout.ts +30 -30
  306. package/src/vibe/client.ts +878 -877
  307. package/src/vibe/enterprise-auth.ts +208 -208
  308. package/src/vibe/errors.ts +209 -209
  309. package/src/vibe/filters.ts +52 -0
  310. package/src/vibe/generic.ts +340 -339
  311. package/src/vibe/hooks/index.ts +367 -367
  312. package/src/vibe/index.ts +133 -133
  313. package/src/vibe/sessions.ts +925 -899
  314. package/src/vibe/types.ts +577 -577
@@ -1,514 +1,514 @@
1
- /**
2
- * Enhanced Profile Page for @payez/next-mvp
3
- *
4
- * Implements BAPert's Member Self-Service spec with:
5
- * - Identity header (avatar, name, email, phone, member since)
6
- * - Personal info section (first name, last name, title, bio)
7
- * - Contact info section (secondary email/phone, website)
8
- * - Address section with state/country dropdowns
9
- * - Edit capabilities with inline forms
10
- *
11
- * @see docs/specs/MEMBER_SELF_SERVICE_SPEC.md
12
- */
13
-
14
- 'use client';
15
-
16
- import React, { useState, useEffect } from 'react';
17
- import { useProfile } from '../../hooks/useProfile';
18
- import { useLayout, useColors } from '../../theme/useTheme';
19
-
20
- // Types matching BAPert's spec
21
- interface ProfileData {
22
- user_id?: number | string; // Can be either depending on source
23
- account_id?: string;
24
-
25
- identity?: {
26
- email: string;
27
- email_confirmed?: boolean;
28
- phone_number?: string;
29
- phone_confirmed?: boolean;
30
- full_name?: string;
31
- avatar_url?: string | null;
32
- created_at?: string;
33
- last_sign_in?: string;
34
- last_activity?: string;
35
- };
36
-
37
- personal_info?: {
38
- first_name?: string;
39
- last_name?: string;
40
- title?: string;
41
- bio?: string;
42
- };
43
-
44
- contact_info?: {
45
- secondary_email?: string;
46
- secondary_phone?: string;
47
- website?: string;
48
- preferred_contact_method?: 'email' | 'sms' | 'both';
49
- };
50
-
51
- address?: {
52
- address_line_1?: string;
53
- address_line_2?: string;
54
- city?: string;
55
- state_id?: number;
56
- state_name?: string;
57
- postal_code?: string;
58
- country_code?: string;
59
- country_name?: string;
60
- };
61
-
62
- // Legacy flat fields for backwards compatibility
63
- email?: string;
64
- email_confirmed?: boolean;
65
- phone_number?: string;
66
- phone_confirmed?: boolean;
67
- first_name?: string;
68
- last_name?: string;
69
- full_name?: string;
70
- two_factor_enabled?: boolean;
71
- roles?: string[];
72
- // `contact_information` was declared here and read by the mapper below. The MEMBER
73
- // endpoint never emits it - it belongs to AdminUserProfileResponse. The CORRECT
74
- // shapes were already declared above (contact_info :44, address :51); only the
75
- // mapper was pointed at the wrong field. Removed so nothing can read it again.
76
- }
77
-
78
- // An all-null object is not data. The member profile DTOs are always instantiated
79
- // server-side (UserService.GetUserProfileAsync), so `obj ? ... : undefined` cannot
80
- // tell "has an address" from "has an empty address shell".
81
- function hasAnyValue(obj: Record<string, unknown> | undefined | null): boolean {
82
- return !!obj && Object.values(obj).some(v => v !== null && v !== undefined && v !== '');
83
- }
84
-
85
- interface EditableSectionProps {
86
- title: string;
87
- children: React.ReactNode;
88
- onEdit?: () => void;
89
- isEditing?: boolean;
90
- onSave?: () => void;
91
- onCancel?: () => void;
92
- isDarkMode: boolean;
93
- }
94
-
95
- function EditableSection({
96
- title,
97
- children,
98
- onEdit,
99
- isEditing,
100
- onSave,
101
- onCancel,
102
- isDarkMode
103
- }: EditableSectionProps) {
104
- const cardBg = isDarkMode ? 'bg-slate-800' : 'bg-white';
105
- const borderColor = isDarkMode ? 'border-slate-700' : 'border-gray-200';
106
- const textPrimary = isDarkMode ? 'text-white' : 'text-gray-900';
107
- const textSecondary = isDarkMode ? 'text-slate-300' : 'text-gray-600';
108
-
109
- return (
110
- <div className={`rounded-lg border ${cardBg} ${borderColor}`}>
111
- <div className={`flex items-center justify-between px-6 py-4 border-b ${borderColor}`}>
112
- <h2 className={`text-lg font-semibold ${textPrimary}`}>{title}</h2>
113
- {!isEditing && onEdit && (
114
- <button
115
- onClick={onEdit}
116
- className={`text-sm font-medium ${isDarkMode ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
117
- >
118
- Edit
119
- </button>
120
- )}
121
- {isEditing && (
122
- <div className="flex items-center gap-2">
123
- <button
124
- onClick={onCancel}
125
- className={`text-sm font-medium ${textSecondary} hover:${textPrimary}`}
126
- >
127
- Cancel
128
- </button>
129
- <button
130
- onClick={onSave}
131
- className="text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 px-3 py-1 rounded"
132
- >
133
- Save
134
- </button>
135
- </div>
136
- )}
137
- </div>
138
- <div className="px-6 py-4">
139
- {children}
140
- </div>
141
- </div>
142
- );
143
- }
144
-
145
- interface FieldRowProps {
146
- label: string;
147
- value?: string | React.ReactNode;
148
- verified?: boolean;
149
- action?: React.ReactNode;
150
- isDarkMode: boolean;
151
- }
152
-
153
- function FieldRow({ label, value, verified, action, isDarkMode }: FieldRowProps) {
154
- const textPrimary = isDarkMode ? 'text-white' : 'text-gray-900';
155
- const textSecondary = isDarkMode ? 'text-slate-400' : 'text-gray-500';
156
-
157
- return (
158
- <div className="flex items-center justify-between py-3 border-b last:border-b-0 border-slate-700/30">
159
- <div className="flex-1">
160
- <p className={`text-sm ${textSecondary}`}>{label}</p>
161
- <div className="flex items-center gap-2 mt-1">
162
- <span className={textPrimary}>{value || '—'}</span>
163
- {verified !== undefined && (
164
- <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
165
- verified ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400'
166
- }`}>
167
- {verified ? '✓ Verified' : 'Not Verified'}
168
- </span>
169
- )}
170
- </div>
171
- </div>
172
- {action && <div className="ml-4">{action}</div>}
173
- </div>
174
- );
175
- }
176
-
177
- function formatDate(dateString?: string): string {
178
- if (!dateString) return '—';
179
- try {
180
- const date = new Date(dateString);
181
- return date.toLocaleDateString('en-US', {
182
- year: 'numeric',
183
- month: 'long',
184
- day: 'numeric'
185
- });
186
- } catch {
187
- return dateString;
188
- }
189
- }
190
-
191
- function formatRelativeTime(dateString?: string): string {
192
- if (!dateString) return '—';
193
- try {
194
- const date = new Date(dateString);
195
- const now = new Date();
196
- const diffMs = now.getTime() - date.getTime();
197
- const diffMins = Math.floor(diffMs / 60000);
198
- const diffHours = Math.floor(diffMs / 3600000);
199
- const diffDays = Math.floor(diffMs / 86400000);
200
-
201
- if (diffMins < 1) return 'Just now';
202
- if (diffMins < 60) return `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`;
203
- if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
204
- if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
205
- return formatDate(dateString);
206
- } catch {
207
- return dateString;
208
- }
209
- }
210
-
211
- export default function EnhancedProfilePage() {
212
- const { data: rawProfile, isLoading, error, refetch } = useProfile();
213
- const layout = useLayout();
214
- const colors = useColors();
215
-
216
- // Normalize profile data (handles both new structured and legacy flat formats)
217
- const profile: ProfileData = React.useMemo(() => {
218
- if (!rawProfile) return {};
219
-
220
- // If data already has identity section, use as-is
221
- if (rawProfile.identity) {
222
- return rawProfile as ProfileData;
223
- }
224
-
225
- // Convert legacy flat format to structured format
226
- return {
227
- user_id: rawProfile.user_id,
228
- identity: {
229
- email: rawProfile.email || '',
230
- email_confirmed: rawProfile.email_confirmed,
231
- phone_number: rawProfile.phone_number,
232
- phone_confirmed: rawProfile.phone_confirmed,
233
- full_name: rawProfile.full_name ||
234
- [rawProfile.first_name, rawProfile.last_name].filter(Boolean).join(' ') ||
235
- rawProfile.email,
236
- avatar_url: null,
237
- created_at: undefined,
238
- last_sign_in: undefined,
239
- },
240
- personal_info: {
241
- first_name: rawProfile.first_name,
242
- last_name: rawProfile.last_name,
243
- title: undefined,
244
- bio: undefined,
245
- },
246
- // Both blocks used to be read off `rawProfile.contact_information`, which the
247
- // MEMBER endpoint NEVER emits - that name belongs to AdminUserProfileResponse
248
- // (its only 2 occurrences in PayEz-Core are in that admin DTO).
249
- // /api/account/profile returns UserProfileResponse: `contact_info` plus a
250
- // SEPARATE TOP-LEVEL `address`. Both guards therefore always took the false
251
- // branch and both blocks rendered permanently empty for every member.
252
- //
253
- // NOT a rename: `address` is a SIBLING of contact_info, not nested inside it,
254
- // so contact_information -> contact_info alone fixes Contact and leaves ADDRESS
255
- // DEAD. Inner names are snake_case on both DTOs; the old camelCase reads were
256
- // shaped for the ADMIN payload, which is where those keys really are camelCase.
257
- // GUARD ON CONTENT, NOT PRESENCE. UserService.GetUserProfileAsync ALWAYS
258
- // instantiates both DTOs (ContactInfo = new ProfileContactInfoDto {...},
259
- // Address = new AddressDto {...}), so both arrive NON-NULL with all-null
260
- // members when the member has nothing on file. A truthiness guard always
261
- // passes and would render a card full of blank fields - the hazard QAPert
262
- // named in 41360 for the naive rename, which applies just as much to
263
- // reading the CORRECT fields.
264
- contact_info: hasAnyValue(rawProfile.contact_info) ? {
265
- secondary_email: rawProfile.contact_info?.secondary_email,
266
- secondary_phone: rawProfile.contact_info?.secondary_phone,
267
- website: rawProfile.contact_info?.website,
268
- preferred_contact_method: rawProfile.contact_info?.preferred_contact_method,
269
- } : undefined,
270
- address: hasAnyValue(rawProfile.address) ? {
271
- address_line_1: rawProfile.address?.address_line_1,
272
- address_line_2: rawProfile.address?.address_line_2,
273
- city: rawProfile.address?.city,
274
- state_id: rawProfile.address?.state_id,
275
- state_name: rawProfile.address?.state_name,
276
- postal_code: rawProfile.address?.postal_code,
277
- country_code: rawProfile.address?.country_code,
278
- country_name: rawProfile.address?.country_name,
279
- } : undefined,
280
- two_factor_enabled: rawProfile.two_factor_enabled,
281
- roles: rawProfile.roles,
282
- };
283
- }, [rawProfile]);
284
-
285
- // Determine dark mode
286
- const isDarkMode = colors?.background?.includes('slate-9') ||
287
- colors?.background?.includes('gray-9') ||
288
- colors?.background?.includes('dark') ||
289
- colors?.card?.includes('slate-8');
290
-
291
- // Theme classes
292
- const bgClass = isDarkMode ? 'bg-slate-900' : 'bg-gray-50';
293
- const textPrimary = isDarkMode ? 'text-white' : 'text-gray-900';
294
- const textSecondary = isDarkMode ? 'text-slate-300' : 'text-gray-600';
295
- const textMuted = isDarkMode ? 'text-slate-400' : 'text-gray-500';
296
- const cardBg = isDarkMode ? 'bg-slate-800' : 'bg-white';
297
- const borderColor = isDarkMode ? 'border-slate-700' : 'border-gray-200';
298
-
299
- // Loading state
300
- if (isLoading || !profile.identity) {
301
- return (
302
- <div className={`min-h-screen ${bgClass}`}>
303
- <div className="flex items-center justify-center min-h-[400px]">
304
- <div className="flex flex-col items-center space-y-4">
305
- <svg className={`animate-spin h-8 w-8 ${textMuted}`} viewBox="0 0 24 24" fill="none">
306
- <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
307
- <path className="opacity-75" fill="currentColor" d="M4 12 a 8 8 0 0 1 8 -8 v 4 a 4 4 0 0 0 -4 4 H4 z" />
308
- </svg>
309
- <p className={textMuted}>Loading profile...</p>
310
- {error && (
311
- <p className="text-red-500 text-sm mt-2">{(error as Error).message}</p>
312
- )}
313
- </div>
314
- </div>
315
- </div>
316
- );
317
- }
318
-
319
- const { identity, personal_info, contact_info, address } = profile;
320
- const userInitial = identity?.email?.charAt(0).toUpperCase() || 'U';
321
-
322
- return (
323
- <div className={`min-h-screen ${bgClass}`}>
324
- <div className={`${layout?.maxWidth || 'max-w-3xl'} mx-auto ${layout?.padding || 'p-6'} space-y-6`}>
325
- {/* Page Header */}
326
- <div className="flex items-center justify-between">
327
- <h1 className={`text-2xl font-bold ${textPrimary}`}>Profile</h1>
328
- </div>
329
-
330
- {/* Identity Header Card */}
331
- <div className={`rounded-lg border ${cardBg} ${borderColor} p-6`}>
332
- <div className="flex items-start gap-6">
333
- {/* Avatar */}
334
- <div className="flex-shrink-0">
335
- {identity?.avatar_url ? (
336
- <img
337
- src={identity.avatar_url}
338
- alt="Profile"
339
- className="w-24 h-24 rounded-full object-cover"
340
- />
341
- ) : (
342
- <div className="w-24 h-24 rounded-full bg-blue-600 flex items-center justify-center">
343
- <span className="text-white font-semibold text-3xl">{userInitial}</span>
344
- </div>
345
- )}
346
- <button className={`mt-2 text-sm ${isDarkMode ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'} w-full text-center`}>
347
- Change Photo
348
- </button>
349
- </div>
350
-
351
- {/* Identity Info */}
352
- <div className="flex-1">
353
- <h2 className={`text-2xl font-bold ${textPrimary}`}>
354
- {identity?.full_name || identity?.email}
355
- </h2>
356
-
357
- <div className="mt-2 space-y-1">
358
- <div className="flex items-center gap-2">
359
- <span className={textSecondary}>{identity?.email}</span>
360
- {identity?.email_confirmed && (
361
- <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
362
- ✓
363
- </span>
364
- )}
365
- </div>
366
-
367
- {identity?.phone_number && (
368
- <div className="flex items-center gap-2">
369
- <span className={textSecondary}>{identity.phone_number}</span>
370
- {identity?.phone_confirmed && (
371
- <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
372
- ✓
373
- </span>
374
- )}
375
- </div>
376
- )}
377
- </div>
378
-
379
- {/* Account metadata */}
380
- <div className={`mt-4 pt-4 border-t ${borderColor} text-sm ${textMuted} space-y-1`}>
381
- {identity?.created_at && (
382
- <p>Member since {formatDate(identity.created_at)}</p>
383
- )}
384
- {identity?.last_activity && (
385
- <p>Last active {formatRelativeTime(identity.last_activity)}</p>
386
- )}
387
- {profile.user_id && (
388
- <p>Account ID: USR-{profile.user_id}</p>
389
- )}
390
- </div>
391
- </div>
392
- </div>
393
- </div>
394
-
395
- {/* Personal Information */}
396
- <EditableSection title="Personal Information" isDarkMode={isDarkMode}>
397
- <div className="space-y-0">
398
- <FieldRow
399
- label="First Name"
400
- value={personal_info?.first_name}
401
- isDarkMode={isDarkMode}
402
- />
403
- <FieldRow
404
- label="Last Name"
405
- value={personal_info?.last_name}
406
- isDarkMode={isDarkMode}
407
- />
408
- <FieldRow
409
- label="Title"
410
- value={personal_info?.title}
411
- isDarkMode={isDarkMode}
412
- />
413
- <FieldRow
414
- label="Bio"
415
- value={personal_info?.bio}
416
- isDarkMode={isDarkMode}
417
- />
418
- </div>
419
- </EditableSection>
420
-
421
- {/* Contact Information */}
422
- <EditableSection title="Contact Information" isDarkMode={isDarkMode}>
423
- <div className="space-y-0">
424
- <FieldRow
425
- label="Primary Email"
426
- value={identity?.email}
427
- verified={identity?.email_confirmed}
428
- action={
429
- <button className={`text-sm ${isDarkMode ? 'text-blue-400' : 'text-blue-600'}`}>
430
- Change
431
- </button>
432
- }
433
- isDarkMode={isDarkMode}
434
- />
435
- <FieldRow
436
- label="Primary Phone"
437
- value={identity?.phone_number}
438
- verified={identity?.phone_confirmed}
439
- action={
440
- <button className={`text-sm ${isDarkMode ? 'text-blue-400' : 'text-blue-600'}`}>
441
- Change
442
- </button>
443
- }
444
- isDarkMode={isDarkMode}
445
- />
446
- <FieldRow
447
- label="Secondary Email"
448
- value={contact_info?.secondary_email}
449
- isDarkMode={isDarkMode}
450
- />
451
- <FieldRow
452
- label="Secondary Phone"
453
- value={contact_info?.secondary_phone}
454
- isDarkMode={isDarkMode}
455
- />
456
- <FieldRow
457
- label="Website"
458
- value={contact_info?.website}
459
- isDarkMode={isDarkMode}
460
- />
461
- <FieldRow
462
- label="Preferred Contact"
463
- value={contact_info?.preferred_contact_method ?
464
- contact_info.preferred_contact_method.charAt(0).toUpperCase() +
465
- contact_info.preferred_contact_method.slice(1) : undefined
466
- }
467
- isDarkMode={isDarkMode}
468
- />
469
- </div>
470
- </EditableSection>
471
-
472
- {/* Address */}
473
- <EditableSection title="Address" isDarkMode={isDarkMode}>
474
- {address?.address_line_1 ? (
475
- <div className={textPrimary}>
476
- <p>{address.address_line_1}</p>
477
- {address.address_line_2 && <p>{address.address_line_2}</p>}
478
- <p>
479
- {[address.city, address.state_name, address.postal_code]
480
- .filter(Boolean)
481
- .join(', ')}
482
- </p>
483
- <p>{address.country_name || address.country_code}</p>
484
- </div>
485
- ) : (
486
- <p className={textMuted}>No address on file</p>
487
- )}
488
- </EditableSection>
489
-
490
- {/* Quick Links */}
491
- <div className="flex flex-wrap justify-center gap-6 pt-4">
492
- <a
493
- href="/account/subscription"
494
- className={`text-sm ${isDarkMode ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
495
- >
496
- Subscription & Billing →
497
- </a>
498
- <a
499
- href="/account/security"
500
- className={`text-sm ${isDarkMode ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
501
- >
502
- Security Settings →
503
- </a>
504
- <a
505
- href="/account/settings"
506
- className={`text-sm ${isDarkMode ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
507
- >
508
- Preferences →
509
- </a>
510
- </div>
511
- </div>
512
- </div>
513
- );
514
- }
1
+ /**
2
+ * Enhanced Profile Page for @payez/next-mvp
3
+ *
4
+ * Implements BAPert's Member Self-Service spec with:
5
+ * - Identity header (avatar, name, email, phone, member since)
6
+ * - Personal info section (first name, last name, title, bio)
7
+ * - Contact info section (secondary email/phone, website)
8
+ * - Address section with state/country dropdowns
9
+ * - Edit capabilities with inline forms
10
+ *
11
+ * @see docs/specs/MEMBER_SELF_SERVICE_SPEC.md
12
+ */
13
+
14
+ 'use client';
15
+
16
+ import React, { useState, useEffect } from 'react';
17
+ import { useProfile } from '../../hooks/useProfile';
18
+ import { useLayout, useColors } from '../../theme/useTheme';
19
+
20
+ // Types matching BAPert's spec
21
+ interface ProfileData {
22
+ user_id?: number | string; // Can be either depending on source
23
+ account_id?: string;
24
+
25
+ identity?: {
26
+ email: string;
27
+ email_confirmed?: boolean;
28
+ phone_number?: string;
29
+ phone_confirmed?: boolean;
30
+ full_name?: string;
31
+ avatar_url?: string | null;
32
+ created_at?: string;
33
+ last_sign_in?: string;
34
+ last_activity?: string;
35
+ };
36
+
37
+ personal_info?: {
38
+ first_name?: string;
39
+ last_name?: string;
40
+ title?: string;
41
+ bio?: string;
42
+ };
43
+
44
+ contact_info?: {
45
+ secondary_email?: string;
46
+ secondary_phone?: string;
47
+ website?: string;
48
+ preferred_contact_method?: 'email' | 'sms' | 'both';
49
+ };
50
+
51
+ address?: {
52
+ address_line_1?: string;
53
+ address_line_2?: string;
54
+ city?: string;
55
+ state_id?: number;
56
+ state_name?: string;
57
+ postal_code?: string;
58
+ country_code?: string;
59
+ country_name?: string;
60
+ };
61
+
62
+ // Legacy flat fields for backwards compatibility
63
+ email?: string;
64
+ email_confirmed?: boolean;
65
+ phone_number?: string;
66
+ phone_confirmed?: boolean;
67
+ first_name?: string;
68
+ last_name?: string;
69
+ full_name?: string;
70
+ two_factor_enabled?: boolean;
71
+ roles?: string[];
72
+ // `contact_information` was declared here and read by the mapper below. The MEMBER
73
+ // endpoint never emits it - it belongs to AdminUserProfileResponse. The CORRECT
74
+ // shapes were already declared above (contact_info :44, address :51); only the
75
+ // mapper was pointed at the wrong field. Removed so nothing can read it again.
76
+ }
77
+
78
+ // An all-null object is not data. The member profile DTOs are always instantiated
79
+ // server-side (UserService.GetUserProfileAsync), so `obj ? ... : undefined` cannot
80
+ // tell "has an address" from "has an empty address shell".
81
+ function hasAnyValue(obj: Record<string, unknown> | undefined | null): boolean {
82
+ return !!obj && Object.values(obj).some(v => v !== null && v !== undefined && v !== '');
83
+ }
84
+
85
+ interface EditableSectionProps {
86
+ title: string;
87
+ children: React.ReactNode;
88
+ onEdit?: () => void;
89
+ isEditing?: boolean;
90
+ onSave?: () => void;
91
+ onCancel?: () => void;
92
+ isDarkMode: boolean;
93
+ }
94
+
95
+ function EditableSection({
96
+ title,
97
+ children,
98
+ onEdit,
99
+ isEditing,
100
+ onSave,
101
+ onCancel,
102
+ isDarkMode
103
+ }: EditableSectionProps) {
104
+ const cardBg = isDarkMode ? 'bg-slate-800' : 'bg-white';
105
+ const borderColor = isDarkMode ? 'border-slate-700' : 'border-gray-200';
106
+ const textPrimary = isDarkMode ? 'text-white' : 'text-gray-900';
107
+ const textSecondary = isDarkMode ? 'text-slate-300' : 'text-gray-600';
108
+
109
+ return (
110
+ <div className={`rounded-lg border ${cardBg} ${borderColor}`}>
111
+ <div className={`flex items-center justify-between px-6 py-4 border-b ${borderColor}`}>
112
+ <h2 className={`text-lg font-semibold ${textPrimary}`}>{title}</h2>
113
+ {!isEditing && onEdit && (
114
+ <button
115
+ onClick={onEdit}
116
+ className={`text-sm font-medium ${isDarkMode ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
117
+ >
118
+ Edit
119
+ </button>
120
+ )}
121
+ {isEditing && (
122
+ <div className="flex items-center gap-2">
123
+ <button
124
+ onClick={onCancel}
125
+ className={`text-sm font-medium ${textSecondary} hover:${textPrimary}`}
126
+ >
127
+ Cancel
128
+ </button>
129
+ <button
130
+ onClick={onSave}
131
+ className="text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 px-3 py-1 rounded"
132
+ >
133
+ Save
134
+ </button>
135
+ </div>
136
+ )}
137
+ </div>
138
+ <div className="px-6 py-4">
139
+ {children}
140
+ </div>
141
+ </div>
142
+ );
143
+ }
144
+
145
+ interface FieldRowProps {
146
+ label: string;
147
+ value?: string | React.ReactNode;
148
+ verified?: boolean;
149
+ action?: React.ReactNode;
150
+ isDarkMode: boolean;
151
+ }
152
+
153
+ function FieldRow({ label, value, verified, action, isDarkMode }: FieldRowProps) {
154
+ const textPrimary = isDarkMode ? 'text-white' : 'text-gray-900';
155
+ const textSecondary = isDarkMode ? 'text-slate-400' : 'text-gray-500';
156
+
157
+ return (
158
+ <div className="flex items-center justify-between py-3 border-b last:border-b-0 border-slate-700/30">
159
+ <div className="flex-1">
160
+ <p className={`text-sm ${textSecondary}`}>{label}</p>
161
+ <div className="flex items-center gap-2 mt-1">
162
+ <span className={textPrimary}>{value || '—'}</span>
163
+ {verified !== undefined && (
164
+ <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
165
+ verified ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400'
166
+ }`}>
167
+ {verified ? '✓ Verified' : 'Not Verified'}
168
+ </span>
169
+ )}
170
+ </div>
171
+ </div>
172
+ {action && <div className="ml-4">{action}</div>}
173
+ </div>
174
+ );
175
+ }
176
+
177
+ function formatDate(dateString?: string): string {
178
+ if (!dateString) return '—';
179
+ try {
180
+ const date = new Date(dateString);
181
+ return date.toLocaleDateString('en-US', {
182
+ year: 'numeric',
183
+ month: 'long',
184
+ day: 'numeric'
185
+ });
186
+ } catch {
187
+ return dateString;
188
+ }
189
+ }
190
+
191
+ function formatRelativeTime(dateString?: string): string {
192
+ if (!dateString) return '—';
193
+ try {
194
+ const date = new Date(dateString);
195
+ const now = new Date();
196
+ const diffMs = now.getTime() - date.getTime();
197
+ const diffMins = Math.floor(diffMs / 60000);
198
+ const diffHours = Math.floor(diffMs / 3600000);
199
+ const diffDays = Math.floor(diffMs / 86400000);
200
+
201
+ if (diffMins < 1) return 'Just now';
202
+ if (diffMins < 60) return `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`;
203
+ if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
204
+ if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
205
+ return formatDate(dateString);
206
+ } catch {
207
+ return dateString;
208
+ }
209
+ }
210
+
211
+ export default function EnhancedProfilePage() {
212
+ const { data: rawProfile, isLoading, error, refetch } = useProfile();
213
+ const layout = useLayout();
214
+ const colors = useColors();
215
+
216
+ // Normalize profile data (handles both new structured and legacy flat formats)
217
+ const profile: ProfileData = React.useMemo(() => {
218
+ if (!rawProfile) return {};
219
+
220
+ // If data already has identity section, use as-is
221
+ if (rawProfile.identity) {
222
+ return rawProfile as ProfileData;
223
+ }
224
+
225
+ // Convert legacy flat format to structured format
226
+ return {
227
+ user_id: rawProfile.user_id,
228
+ identity: {
229
+ email: rawProfile.email || '',
230
+ email_confirmed: rawProfile.email_confirmed,
231
+ phone_number: rawProfile.phone_number,
232
+ phone_confirmed: rawProfile.phone_confirmed,
233
+ full_name: rawProfile.full_name ||
234
+ [rawProfile.first_name, rawProfile.last_name].filter(Boolean).join(' ') ||
235
+ rawProfile.email,
236
+ avatar_url: null,
237
+ created_at: undefined,
238
+ last_sign_in: undefined,
239
+ },
240
+ personal_info: {
241
+ first_name: rawProfile.first_name,
242
+ last_name: rawProfile.last_name,
243
+ title: undefined,
244
+ bio: undefined,
245
+ },
246
+ // Both blocks used to be read off `rawProfile.contact_information`, which the
247
+ // MEMBER endpoint NEVER emits - that name belongs to AdminUserProfileResponse
248
+ // (its only 2 occurrences in PayEz-Core are in that admin DTO).
249
+ // /api/account/profile returns UserProfileResponse: `contact_info` plus a
250
+ // SEPARATE TOP-LEVEL `address`. Both guards therefore always took the false
251
+ // branch and both blocks rendered permanently empty for every member.
252
+ //
253
+ // NOT a rename: `address` is a SIBLING of contact_info, not nested inside it,
254
+ // so contact_information -> contact_info alone fixes Contact and leaves ADDRESS
255
+ // DEAD. Inner names are snake_case on both DTOs; the old camelCase reads were
256
+ // shaped for the ADMIN payload, which is where those keys really are camelCase.
257
+ // GUARD ON CONTENT, NOT PRESENCE. UserService.GetUserProfileAsync ALWAYS
258
+ // instantiates both DTOs (ContactInfo = new ProfileContactInfoDto {...},
259
+ // Address = new AddressDto {...}), so both arrive NON-NULL with all-null
260
+ // members when the member has nothing on file. A truthiness guard always
261
+ // passes and would render a card full of blank fields - the hazard QAPert
262
+ // named in 41360 for the naive rename, which applies just as much to
263
+ // reading the CORRECT fields.
264
+ contact_info: hasAnyValue(rawProfile.contact_info) ? {
265
+ secondary_email: rawProfile.contact_info?.secondary_email,
266
+ secondary_phone: rawProfile.contact_info?.secondary_phone,
267
+ website: rawProfile.contact_info?.website,
268
+ preferred_contact_method: rawProfile.contact_info?.preferred_contact_method,
269
+ } : undefined,
270
+ address: hasAnyValue(rawProfile.address) ? {
271
+ address_line_1: rawProfile.address?.address_line_1,
272
+ address_line_2: rawProfile.address?.address_line_2,
273
+ city: rawProfile.address?.city,
274
+ state_id: rawProfile.address?.state_id,
275
+ state_name: rawProfile.address?.state_name,
276
+ postal_code: rawProfile.address?.postal_code,
277
+ country_code: rawProfile.address?.country_code,
278
+ country_name: rawProfile.address?.country_name,
279
+ } : undefined,
280
+ two_factor_enabled: rawProfile.two_factor_enabled,
281
+ roles: rawProfile.roles,
282
+ };
283
+ }, [rawProfile]);
284
+
285
+ // Determine dark mode
286
+ const isDarkMode = colors?.background?.includes('slate-9') ||
287
+ colors?.background?.includes('gray-9') ||
288
+ colors?.background?.includes('dark') ||
289
+ colors?.card?.includes('slate-8');
290
+
291
+ // Theme classes
292
+ const bgClass = isDarkMode ? 'bg-slate-900' : 'bg-gray-50';
293
+ const textPrimary = isDarkMode ? 'text-white' : 'text-gray-900';
294
+ const textSecondary = isDarkMode ? 'text-slate-300' : 'text-gray-600';
295
+ const textMuted = isDarkMode ? 'text-slate-400' : 'text-gray-500';
296
+ const cardBg = isDarkMode ? 'bg-slate-800' : 'bg-white';
297
+ const borderColor = isDarkMode ? 'border-slate-700' : 'border-gray-200';
298
+
299
+ // Loading state
300
+ if (isLoading || !profile.identity) {
301
+ return (
302
+ <div className={`min-h-screen ${bgClass}`}>
303
+ <div className="flex items-center justify-center min-h-[400px]">
304
+ <div className="flex flex-col items-center space-y-4">
305
+ <svg className={`animate-spin h-8 w-8 ${textMuted}`} viewBox="0 0 24 24" fill="none">
306
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
307
+ <path className="opacity-75" fill="currentColor" d="M4 12 a 8 8 0 0 1 8 -8 v 4 a 4 4 0 0 0 -4 4 H4 z" />
308
+ </svg>
309
+ <p className={textMuted}>Loading profile...</p>
310
+ {error && (
311
+ <p className="text-red-500 text-sm mt-2">{(error as Error).message}</p>
312
+ )}
313
+ </div>
314
+ </div>
315
+ </div>
316
+ );
317
+ }
318
+
319
+ const { identity, personal_info, contact_info, address } = profile;
320
+ const userInitial = identity?.email?.charAt(0).toUpperCase() || 'U';
321
+
322
+ return (
323
+ <div className={`min-h-screen ${bgClass}`}>
324
+ <div className={`${layout?.maxWidth || 'max-w-3xl'} mx-auto ${layout?.padding || 'p-6'} space-y-6`}>
325
+ {/* Page Header */}
326
+ <div className="flex items-center justify-between">
327
+ <h1 className={`text-2xl font-bold ${textPrimary}`}>Profile</h1>
328
+ </div>
329
+
330
+ {/* Identity Header Card */}
331
+ <div className={`rounded-lg border ${cardBg} ${borderColor} p-6`}>
332
+ <div className="flex items-start gap-6">
333
+ {/* Avatar */}
334
+ <div className="flex-shrink-0">
335
+ {identity?.avatar_url ? (
336
+ <img
337
+ src={identity.avatar_url}
338
+ alt="Profile"
339
+ className="w-24 h-24 rounded-full object-cover"
340
+ />
341
+ ) : (
342
+ <div className="w-24 h-24 rounded-full bg-blue-600 flex items-center justify-center">
343
+ <span className="text-white font-semibold text-3xl">{userInitial}</span>
344
+ </div>
345
+ )}
346
+ <button className={`mt-2 text-sm ${isDarkMode ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'} w-full text-center`}>
347
+ Change Photo
348
+ </button>
349
+ </div>
350
+
351
+ {/* Identity Info */}
352
+ <div className="flex-1">
353
+ <h2 className={`text-2xl font-bold ${textPrimary}`}>
354
+ {identity?.full_name || identity?.email}
355
+ </h2>
356
+
357
+ <div className="mt-2 space-y-1">
358
+ <div className="flex items-center gap-2">
359
+ <span className={textSecondary}>{identity?.email}</span>
360
+ {identity?.email_confirmed && (
361
+ <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
362
+ ✓
363
+ </span>
364
+ )}
365
+ </div>
366
+
367
+ {identity?.phone_number && (
368
+ <div className="flex items-center gap-2">
369
+ <span className={textSecondary}>{identity.phone_number}</span>
370
+ {identity?.phone_confirmed && (
371
+ <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
372
+ ✓
373
+ </span>
374
+ )}
375
+ </div>
376
+ )}
377
+ </div>
378
+
379
+ {/* Account metadata */}
380
+ <div className={`mt-4 pt-4 border-t ${borderColor} text-sm ${textMuted} space-y-1`}>
381
+ {identity?.created_at && (
382
+ <p>Member since {formatDate(identity.created_at)}</p>
383
+ )}
384
+ {identity?.last_activity && (
385
+ <p>Last active {formatRelativeTime(identity.last_activity)}</p>
386
+ )}
387
+ {profile.user_id && (
388
+ <p>Account ID: USR-{profile.user_id}</p>
389
+ )}
390
+ </div>
391
+ </div>
392
+ </div>
393
+ </div>
394
+
395
+ {/* Personal Information */}
396
+ <EditableSection title="Personal Information" isDarkMode={isDarkMode}>
397
+ <div className="space-y-0">
398
+ <FieldRow
399
+ label="First Name"
400
+ value={personal_info?.first_name}
401
+ isDarkMode={isDarkMode}
402
+ />
403
+ <FieldRow
404
+ label="Last Name"
405
+ value={personal_info?.last_name}
406
+ isDarkMode={isDarkMode}
407
+ />
408
+ <FieldRow
409
+ label="Title"
410
+ value={personal_info?.title}
411
+ isDarkMode={isDarkMode}
412
+ />
413
+ <FieldRow
414
+ label="Bio"
415
+ value={personal_info?.bio}
416
+ isDarkMode={isDarkMode}
417
+ />
418
+ </div>
419
+ </EditableSection>
420
+
421
+ {/* Contact Information */}
422
+ <EditableSection title="Contact Information" isDarkMode={isDarkMode}>
423
+ <div className="space-y-0">
424
+ <FieldRow
425
+ label="Primary Email"
426
+ value={identity?.email}
427
+ verified={identity?.email_confirmed}
428
+ action={
429
+ <button className={`text-sm ${isDarkMode ? 'text-blue-400' : 'text-blue-600'}`}>
430
+ Change
431
+ </button>
432
+ }
433
+ isDarkMode={isDarkMode}
434
+ />
435
+ <FieldRow
436
+ label="Primary Phone"
437
+ value={identity?.phone_number}
438
+ verified={identity?.phone_confirmed}
439
+ action={
440
+ <button className={`text-sm ${isDarkMode ? 'text-blue-400' : 'text-blue-600'}`}>
441
+ Change
442
+ </button>
443
+ }
444
+ isDarkMode={isDarkMode}
445
+ />
446
+ <FieldRow
447
+ label="Secondary Email"
448
+ value={contact_info?.secondary_email}
449
+ isDarkMode={isDarkMode}
450
+ />
451
+ <FieldRow
452
+ label="Secondary Phone"
453
+ value={contact_info?.secondary_phone}
454
+ isDarkMode={isDarkMode}
455
+ />
456
+ <FieldRow
457
+ label="Website"
458
+ value={contact_info?.website}
459
+ isDarkMode={isDarkMode}
460
+ />
461
+ <FieldRow
462
+ label="Preferred Contact"
463
+ value={contact_info?.preferred_contact_method ?
464
+ contact_info.preferred_contact_method.charAt(0).toUpperCase() +
465
+ contact_info.preferred_contact_method.slice(1) : undefined
466
+ }
467
+ isDarkMode={isDarkMode}
468
+ />
469
+ </div>
470
+ </EditableSection>
471
+
472
+ {/* Address */}
473
+ <EditableSection title="Address" isDarkMode={isDarkMode}>
474
+ {address?.address_line_1 ? (
475
+ <div className={textPrimary}>
476
+ <p>{address.address_line_1}</p>
477
+ {address.address_line_2 && <p>{address.address_line_2}</p>}
478
+ <p>
479
+ {[address.city, address.state_name, address.postal_code]
480
+ .filter(Boolean)
481
+ .join(', ')}
482
+ </p>
483
+ <p>{address.country_name || address.country_code}</p>
484
+ </div>
485
+ ) : (
486
+ <p className={textMuted}>No address on file</p>
487
+ )}
488
+ </EditableSection>
489
+
490
+ {/* Quick Links */}
491
+ <div className="flex flex-wrap justify-center gap-6 pt-4">
492
+ <a
493
+ href="/account/subscription"
494
+ className={`text-sm ${isDarkMode ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
495
+ >
496
+ Subscription & Billing →
497
+ </a>
498
+ <a
499
+ href="/account/security"
500
+ className={`text-sm ${isDarkMode ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
501
+ >
502
+ Security Settings →
503
+ </a>
504
+ <a
505
+ href="/account/settings"
506
+ className={`text-sm ${isDarkMode ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
507
+ >
508
+ Preferences →
509
+ </a>
510
+ </div>
511
+ </div>
512
+ </div>
513
+ );
514
+ }