@salesforce/ui-bundle-template-app-react-template-b2x 2.2.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/CHANGELOG.md +17 -0
  2. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/components/alerts/status-alert.tsx +11 -8
  3. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/components/ui/input.tsx +1 -1
  4. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/authentication/api/userProfileApi.ts +2 -1
  5. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/authentication/authenticationConfig.ts +9 -9
  6. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/authentication/context/AuthContext.tsx +21 -4
  7. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/authentication/forms/auth-form.tsx +15 -1
  8. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/authentication/hooks/form.tsx +1 -1
  9. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/authentication/layouts/privateRouteLayout.tsx +2 -11
  10. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/authentication/pages/ChangePassword.tsx +20 -4
  11. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/authentication/pages/ForgotPassword.tsx +19 -4
  12. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/authentication/pages/Login.tsx +19 -4
  13. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/authentication/pages/Profile.tsx +80 -43
  14. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/authentication/pages/Register.tsx +15 -4
  15. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/authentication/pages/ResetPassword.tsx +19 -4
  16. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/authentication/utils/helpers.ts +15 -52
  17. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/__examples__/pages/AccountObjectDetailPage.tsx +2 -4
  18. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/__examples__/pages/AccountSearch.tsx +7 -15
  19. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/filters/NumericRangeFilter.tsx +9 -5
  20. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/filters/SearchFilter.tsx +5 -3
  21. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/components/filters/TextFilter.tsx +5 -3
  22. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/hooks/useAsyncData.ts +11 -4
  23. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/hooks/useAsyncData.ts +67 -0
  24. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/pages/AccountObjectDetailPage.tsx +2 -4
  25. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/pages/AccountSearch.tsx +7 -15
  26. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/routes.tsx +19 -25
  27. package/dist/package-lock.json +2 -2
  28. package/dist/package.json +1 -1
  29. package/dist/scripts/org-setup.config.json +0 -1
  30. package/dist/scripts/org-setup.mjs +528 -44
  31. package/package.json +1 -1
  32. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/authentication/layout/card-skeleton.tsx +0 -38
  33. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/authentication/layouts/authenticationRouteLayout.tsx +0 -21
  34. package/dist/force-app/main/default/uiBundles/reactexternalapp/src/features/object-search/hooks/useCachedAsyncData.ts +0 -188
package/dist/CHANGELOG.md CHANGED
@@ -3,6 +3,23 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [3.1.0](https://github.com/salesforce-experience-platform-emu/webapps/compare/v3.0.0...v3.1.0) (2026-05-12)
7
+
8
+
9
+ ### Features
10
+
11
+ * post tdx changes @W-22390798 ([#500](https://github.com/salesforce-experience-platform-emu/webapps/issues/500)) ([b611d9e](https://github.com/salesforce-experience-platform-emu/webapps/commit/b611d9ecbadd07d052bd7d9a4f5659dec422bf22))
12
+
13
+
14
+
15
+ ## [3.0.0](https://github.com/salesforce-experience-platform-emu/webapps/compare/v2.2.1...v3.0.0) (2026-05-11)
16
+
17
+ **Note:** Version bump only for package @salesforce/ui-bundle-template-base-sfdx-project
18
+
19
+
20
+
21
+
22
+
6
23
  ## [2.2.1](https://github.com/salesforce-experience-platform-emu/webapps/compare/v2.2.0...v2.2.1) (2026-05-11)
7
24
 
8
25
  **Note:** Version bump only for package @salesforce/ui-bundle-template-base-sfdx-project
@@ -1,5 +1,5 @@
1
1
  import { cva, type VariantProps } from 'class-variance-authority';
2
- import { AlertCircleIcon, CheckCircle2Icon } from 'lucide-react';
2
+ import { AlertCircleIcon, CheckCircle2Icon, InfoIcon } from 'lucide-react';
3
3
  import { Alert, AlertDescription } from '../../components/ui/alert';
4
4
  import { useId } from 'react';
5
5
 
@@ -8,6 +8,7 @@ const statusAlertVariants = cva('', {
8
8
  variant: {
9
9
  error: '',
10
10
  success: '',
11
+ info: 'text-blue-600 *:[svg]:text-current *:data-[slot=alert-description]:text-blue-600/90',
11
12
  },
12
13
  },
13
14
  defaultVariants: {
@@ -18,11 +19,11 @@ const statusAlertVariants = cva('', {
18
19
  interface StatusAlertProps extends VariantProps<typeof statusAlertVariants> {
19
20
  children?: React.ReactNode;
20
21
  /** Alert variant type. @default "error" */
21
- variant?: 'error' | 'success';
22
+ variant?: 'error' | 'success' | 'info';
22
23
  }
23
24
 
24
25
  /**
25
- * Status alert component for displaying error or success messages.
26
+ * Status alert component for displaying error, success, or info messages.
26
27
  * Returns null if no children are provided.
27
28
  */
28
29
  export function StatusAlert({ children, variant = 'error' }: StatusAlertProps) {
@@ -31,6 +32,12 @@ export function StatusAlert({ children, variant = 'error' }: StatusAlertProps) {
31
32
 
32
33
  const isError = variant === 'error';
33
34
 
35
+ const icon = {
36
+ error: <AlertCircleIcon aria-hidden="true" />,
37
+ success: <CheckCircle2Icon aria-hidden="true" />,
38
+ info: <InfoIcon aria-hidden="true" />,
39
+ }[variant];
40
+
34
41
  return (
35
42
  <Alert
36
43
  variant={isError ? 'destructive' : 'default'}
@@ -38,11 +45,7 @@ export function StatusAlert({ children, variant = 'error' }: StatusAlertProps) {
38
45
  aria-describedby={descriptionId}
39
46
  role={isError ? 'alert' : 'status'}
40
47
  >
41
- {isError ? (
42
- <AlertCircleIcon aria-hidden="true" />
43
- ) : (
44
- <CheckCircle2Icon aria-hidden="true" />
45
- )}
48
+ {icon}
46
49
  <AlertDescription id={descriptionId}>{children}</AlertDescription>
47
50
  </Alert>
48
51
  );
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
8
8
  type={type}
9
9
  data-slot="input"
10
10
  className={cn(
11
- 'dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
11
+ 'dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm file:text-foreground placeholder:text-muted-foreground/70 placeholder:italic w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
12
12
  className
13
13
  )}
14
14
  {...props}
@@ -48,7 +48,8 @@ function getUserProfileMutation(fields: string): string {
48
48
 
49
49
  function throwOnGraphQLErrors(response: any): void {
50
50
  if (response?.errors?.length) {
51
- throw new Error(response.errors.map((e: any) => e.message).join("; "));
51
+ console.error("GraphQL request failed", response.errors);
52
+ throw new Error("An unexpected error occurred");
52
53
  }
53
54
  }
54
55
 
@@ -49,13 +49,13 @@ export const AUTH_REDIRECT_PARAM = "startUrl";
49
49
  * Placeholder text constants for authentication form inputs.
50
50
  */
51
51
  export const AUTH_PLACEHOLDERS = {
52
- EMAIL: "asalesforce@example.com",
53
- PASSWORD: "",
54
- PASSWORD_CREATE: "",
55
- PASSWORD_CONFIRM: "",
56
- PASSWORD_NEW: "",
57
- PASSWORD_NEW_CONFIRM: "",
58
- FIRST_NAME: "Astro",
59
- LAST_NAME: "Salesforce",
60
- USERNAME: "asalesforce",
52
+ EMAIL: "e.g. name@example.com",
53
+ PASSWORD: "Enter your password",
54
+ PASSWORD_CREATE: "Create a password",
55
+ PASSWORD_CONFIRM: "Re-enter your password",
56
+ PASSWORD_NEW: "Enter new password",
57
+ PASSWORD_NEW_CONFIRM: "Re-enter new password",
58
+ FIRST_NAME: "e.g. Alex",
59
+ LAST_NAME: "e.g. Smith",
60
+ USERNAME: "e.g. asmith",
61
61
  } as const;
@@ -35,8 +35,8 @@ export function AuthProvider({ children }: AuthProviderProps) {
35
35
  const userData = await getCurrentUser();
36
36
  setUser(userData);
37
37
  } catch (err) {
38
- const errorMessage = err instanceof Error ? err.message : "Authentication failed";
39
- setError(errorMessage);
38
+ console.error("Authentication failed", err);
39
+ setError("Authentication failed");
40
40
  setUser(null);
41
41
  } finally {
42
42
  setLoading(false);
@@ -53,8 +53,25 @@ export function AuthProvider({ children }: AuthProviderProps) {
53
53
  }, []);
54
54
 
55
55
  useEffect(() => {
56
- checkAuth();
57
- }, [checkAuth]);
56
+ let cancelled = false;
57
+ getCurrentUser()
58
+ .then((userData) => {
59
+ if (!cancelled) setUser(userData);
60
+ })
61
+ .catch((err) => {
62
+ console.error("Authentication failed", err);
63
+ if (!cancelled) {
64
+ setError("Authentication failed");
65
+ setUser(null);
66
+ }
67
+ })
68
+ .finally(() => {
69
+ if (!cancelled) setLoading(false);
70
+ });
71
+ return () => {
72
+ cancelled = true;
73
+ };
74
+ }, []);
58
75
 
59
76
  const value: AuthContextType = {
60
77
  user,
@@ -4,6 +4,7 @@ import { FooterLink } from "../footers/footer-link";
4
4
  import { SubmitButton } from "./submit-button";
5
5
  import { CardLayout } from "../../../components/layouts/card-layout";
6
6
  import { useFormContext } from "../hooks/form";
7
+ import { useAuth } from "../context/AuthContext";
7
8
  import { useId } from "react";
8
9
 
9
10
  /**
@@ -15,6 +16,8 @@ interface AuthFormProps extends Omit<React.ComponentProps<"form">, "onSubmit"> {
15
16
  description: string;
16
17
  error?: React.ReactNode;
17
18
  success?: React.ReactNode;
19
+ /** Whether to show the "already logged in" alert and disable submit when authenticated. @default true */
20
+ showAlreadyLoggedIn?: boolean;
18
21
  submit: {
19
22
  text: string;
20
23
  loadingText?: string;
@@ -32,6 +35,10 @@ interface AuthFormProps extends Omit<React.ComponentProps<"form">, "onSubmit"> {
32
35
  * Wraps the specific logic of Login/Register forms with a consistent visual frame (Card),
33
36
  * title, and error alert placement. Extends form element props for flexibility.
34
37
  * This ensures all auth-related pages look and behave similarly.
38
+ *
39
+ * Auth-aware behavior:
40
+ * - While auth state is loading, the submit button is disabled.
41
+ * - If the user is already authenticated, an info alert is shown and submit is disabled.
35
42
  */
36
43
  export function AuthForm({
37
44
  id: providedId,
@@ -39,18 +46,25 @@ export function AuthForm({
39
46
  description,
40
47
  error,
41
48
  success,
49
+ showAlreadyLoggedIn = true,
42
50
  children,
43
51
  submit,
44
52
  footer,
45
53
  ...props
46
54
  }: AuthFormProps) {
47
55
  const form = useFormContext();
56
+ const { isAuthenticated, loading } = useAuth();
48
57
  const generatedId = useId();
49
58
  const id = providedId ?? generatedId;
50
59
 
60
+ const showAuthAlert = showAlreadyLoggedIn && isAuthenticated;
61
+ const isSubmitDisabled = submit.disabled || showAuthAlert || loading;
62
+
51
63
  return (
52
64
  <CardLayout title={title} description={description}>
53
65
  <div className="space-y-6">
66
+ {/* [Dev Note] Auth status alert for authenticated users on public pages */}
67
+ {showAuthAlert && <StatusAlert variant="info">You are already logged in.</StatusAlert>}
54
68
  {/* [Dev Note] Global form error alert (e.g. "Invalid Credentials") */}
55
69
  {error && <StatusAlert variant="error">{error}</StatusAlert>}
56
70
  {success && <StatusAlert variant="success">{success}</StatusAlert>}
@@ -69,7 +83,7 @@ export function AuthForm({
69
83
  form={id}
70
84
  label={submit.text}
71
85
  loadingLabel={submit.loadingText}
72
- disabled={submit.disabled}
86
+ disabled={isSubmitDisabled}
73
87
  className="mt-6"
74
88
  />
75
89
  </form>
@@ -40,7 +40,7 @@ function TextField({
40
40
  const id = providedId ?? generatedId;
41
41
  const descriptionId = `${id}-description`;
42
42
  const errorId = `${id}-error`;
43
- const isInvalid = field.state.meta.isTouched && field.state.meta.errors.length > 0;
43
+ const isInvalid = field.state.meta.isBlurred && field.state.meta.errors.length > 0;
44
44
 
45
45
  // Deduplicate errors by message
46
46
  const errors = field.state.meta.errors;
@@ -1,15 +1,6 @@
1
1
  import { Navigate, Outlet, useLocation } from "react-router";
2
2
  import { useAuth } from "../context/AuthContext";
3
3
  import { AUTH_REDIRECT_PARAM, ROUTES } from "../authenticationConfig";
4
- import { CardSkeleton } from "../layout/card-skeleton";
5
-
6
- export interface PrivateRouteProps {
7
- /**
8
- * Whether to show a card skeleton placeholder while authentication is loading.
9
- * @default false
10
- */
11
- showCardSkeleton?: boolean;
12
- }
13
4
 
14
5
  /**
15
6
  * [Dev Note] Route Guard:
@@ -17,11 +8,11 @@ export interface PrivateRouteProps {
17
8
  * Otherwise, redirects to Login with a 'startUrl' parameter so the user can be
18
9
  * returned to this page after successful login.
19
10
  */
20
- export default function PrivateRoute({ showCardSkeleton = false }: PrivateRouteProps) {
11
+ export default function PrivateRoute() {
21
12
  const { isAuthenticated, loading } = useAuth();
22
13
  const location = useLocation();
23
14
 
24
- if (loading) return showCardSkeleton ? <CardSkeleton contentMaxWidth="md" /> : null;
15
+ if (loading) return null;
25
16
 
26
17
  if (!isAuthenticated) {
27
18
  const searchParams = new URLSearchParams();
@@ -7,7 +7,7 @@ import { useAppForm } from "../hooks/form";
7
7
  import { createDataSDK } from "@salesforce/platform-sdk-data";
8
8
  import { ROUTES, AUTH_PLACEHOLDERS } from "../authenticationConfig";
9
9
  import { newPasswordSchema } from "../authHelpers";
10
- import { handleApiResponse, getErrorMessage } from "../utils/helpers";
10
+ import { ApiError, handleApiResponse } from "../utils/helpers";
11
11
 
12
12
  const changePasswordSchema = z
13
13
  .object({
@@ -17,7 +17,7 @@ const changePasswordSchema = z
17
17
 
18
18
  export default function ChangePassword() {
19
19
  const [success, setSuccess] = useState(false);
20
- const [submitError, setSubmitError] = useState<string | null>(null);
20
+ const [submitError, setSubmitError] = useState<React.ReactNode>(null);
21
21
 
22
22
  const form = useAppForm({
23
23
  defaultValues: { currentPassword: "", newPassword: "", confirmPassword: "" },
@@ -40,11 +40,26 @@ export default function ChangePassword() {
40
40
  Accept: "application/json",
41
41
  },
42
42
  });
43
- await handleApiResponse(response, "Password change failed");
43
+ await handleApiResponse(response);
44
44
  setSuccess(true);
45
45
  form.reset();
46
46
  } catch (err) {
47
- setSubmitError(getErrorMessage(err, "Password change failed"));
47
+ console.error("Password change failed", err);
48
+ if (err instanceof ApiError) {
49
+ setSubmitError(
50
+ err.errors.length === 1 ? (
51
+ err.errors[0]
52
+ ) : (
53
+ <ul>
54
+ {err.errors.map((e, i) => (
55
+ <li key={i}>{e}</li>
56
+ ))}
57
+ </ul>
58
+ ),
59
+ );
60
+ } else {
61
+ setSubmitError("Password change failed");
62
+ }
48
63
  }
49
64
  },
50
65
  onSubmitInvalid: () => {},
@@ -56,6 +71,7 @@ export default function ChangePassword() {
56
71
  <AuthForm
57
72
  title="Change Password"
58
73
  description="Enter your current and new password below"
74
+ showAlreadyLoggedIn={false}
59
75
  error={submitError}
60
76
  success={
61
77
  success && (
@@ -5,7 +5,7 @@ import { AuthForm } from "../forms/auth-form";
5
5
  import { useAppForm } from "../hooks/form";
6
6
  import { createDataSDK } from "@salesforce/platform-sdk-data";
7
7
  import { ROUTES, AUTH_PLACEHOLDERS } from "../authenticationConfig";
8
- import { handleApiResponse, getErrorMessage } from "../utils/helpers";
8
+ import { ApiError, handleApiResponse } from "../utils/helpers";
9
9
 
10
10
  const forgotPasswordSchema = z.object({
11
11
  username: z.string().trim().toLowerCase().email("Please enter a valid username"),
@@ -13,7 +13,7 @@ const forgotPasswordSchema = z.object({
13
13
 
14
14
  export default function ForgotPassword() {
15
15
  const [success, setSuccess] = useState(false);
16
- const [submitError, setSubmitError] = useState<string | null>(null);
16
+ const [submitError, setSubmitError] = useState<React.ReactNode>(null);
17
17
 
18
18
  const form = useAppForm({
19
19
  defaultValues: { username: "" },
@@ -33,10 +33,25 @@ export default function ForgotPassword() {
33
33
  Accept: "application/json",
34
34
  },
35
35
  });
36
- await handleApiResponse(response, "Failed to send reset link");
36
+ await handleApiResponse(response);
37
37
  setSuccess(true);
38
38
  } catch (err) {
39
- setSubmitError(getErrorMessage(err, "Failed to send reset link"));
39
+ console.error("Failed to send reset link", err);
40
+ if (err instanceof ApiError) {
41
+ setSubmitError(
42
+ err.errors.length === 1 ? (
43
+ err.errors[0]
44
+ ) : (
45
+ <ul>
46
+ {err.errors.map((e, i) => (
47
+ <li key={i}>{e}</li>
48
+ ))}
49
+ </ul>
50
+ ),
51
+ );
52
+ } else {
53
+ setSubmitError("Failed to send reset link");
54
+ }
40
55
  }
41
56
  },
42
57
  onSubmitInvalid: () => {},
@@ -7,7 +7,7 @@ import { useAppForm } from "../hooks/form";
7
7
  import { createDataSDK } from "@salesforce/platform-sdk-data";
8
8
  import { ROUTES } from "../authenticationConfig";
9
9
  import { emailSchema, getStartUrl, type AuthResponse } from "../authHelpers";
10
- import { handleApiResponse, getErrorMessage } from "../utils/helpers";
10
+ import { ApiError, handleApiResponse } from "../utils/helpers";
11
11
 
12
12
  const loginSchema = z.object({
13
13
  email: emailSchema,
@@ -17,7 +17,7 @@ const loginSchema = z.object({
17
17
  export default function Login() {
18
18
  const navigate = useNavigate();
19
19
  const [searchParams] = useSearchParams();
20
- const [submitError, setSubmitError] = useState<string | null>(null);
20
+ const [submitError, setSubmitError] = useState<React.ReactNode>(null);
21
21
 
22
22
  const form = useAppForm({
23
23
  defaultValues: { email: "", password: "" },
@@ -43,7 +43,7 @@ export default function Login() {
43
43
  Accept: "application/json",
44
44
  },
45
45
  });
46
- const result = await handleApiResponse<AuthResponse>(response, "Login failed");
46
+ const result = await handleApiResponse<AuthResponse>(response);
47
47
  if (result?.redirectUrl) {
48
48
  // Hard navigate to the URL which establishes the server session cookie
49
49
  window.location.replace(result.redirectUrl);
@@ -52,7 +52,22 @@ export default function Login() {
52
52
  navigate("/", { replace: true });
53
53
  }
54
54
  } catch (err) {
55
- setSubmitError(getErrorMessage(err, "Login failed"));
55
+ console.error("Login failed", err);
56
+ if (err instanceof ApiError) {
57
+ setSubmitError(
58
+ err.errors.length === 1 ? (
59
+ err.errors[0]
60
+ ) : (
61
+ <ul>
62
+ {err.errors.map((e, i) => (
63
+ <li key={i}>{e}</li>
64
+ ))}
65
+ </ul>
66
+ ),
67
+ );
68
+ } else {
69
+ setSubmitError("Login failed");
70
+ }
56
71
  }
57
72
  },
58
73
  onSubmitInvalid: () => {},
@@ -2,14 +2,14 @@ import { useState, useEffect } from "react";
2
2
  import { z } from "zod";
3
3
 
4
4
  import { CenteredPageLayout } from "../layout/centered-page-layout";
5
- import { CardSkeleton } from "../layout/card-skeleton";
6
5
  import { AuthForm } from "../forms/auth-form";
7
6
  import { useAppForm } from "../hooks/form";
8
7
  import { ROUTES } from "../authenticationConfig";
9
8
  import { emailSchema } from "../authHelpers";
10
- import { getErrorMessage } from "../utils/helpers";
11
9
  import { useUser } from "../context/AuthContext";
12
10
  import { fetchUserProfile, updateUserProfile } from "../api/userProfileApi";
11
+ import { Skeleton } from "../../../components/ui/skeleton";
12
+ import { Field, FieldLabel } from "../../../components/ui/field";
13
13
 
14
14
  const optionalString = z
15
15
  .string()
@@ -32,6 +32,38 @@ const profileSchema = z.object({
32
32
 
33
33
  type ProfileFormValues = z.infer<typeof profileSchema>;
34
34
 
35
+ function FieldSkeleton({ label }: { label: string }) {
36
+ return (
37
+ <Field>
38
+ <FieldLabel>{label}</FieldLabel>
39
+ <Skeleton className="h-9 w-full rounded-md" />
40
+ </Field>
41
+ );
42
+ }
43
+
44
+ function ProfileFieldsSkeleton() {
45
+ return (
46
+ <div role="status" aria-live="polite">
47
+ <span className="sr-only">Loading profile…</span>
48
+ <FieldSkeleton label="Email" />
49
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 items-start">
50
+ <FieldSkeleton label="First name" />
51
+ <FieldSkeleton label="Last name" />
52
+ </div>
53
+ <FieldSkeleton label="Phone" />
54
+ <FieldSkeleton label="Street" />
55
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 items-start">
56
+ <FieldSkeleton label="City" />
57
+ <FieldSkeleton label="State" />
58
+ </div>
59
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 items-start">
60
+ <FieldSkeleton label="Postal Code" />
61
+ <FieldSkeleton label="Country" />
62
+ </div>
63
+ </div>
64
+ );
65
+ }
66
+
35
67
  export default function Profile() {
36
68
  const user = useUser();
37
69
  const [profile, setProfile] = useState<ProfileFormValues | null>(null);
@@ -62,7 +94,8 @@ export default function Profile() {
62
94
  setSuccess(true);
63
95
  window.scrollTo({ top: 0, behavior: "smooth" });
64
96
  } catch (err) {
65
- setSubmitError(getErrorMessage(err, "Failed to update profile"));
97
+ console.error("Failed to update profile", err);
98
+ setSubmitError("Failed to update profile");
66
99
  }
67
100
  },
68
101
  onSubmitInvalid: () => {},
@@ -88,10 +121,9 @@ export default function Profile() {
88
121
  }
89
122
  })
90
123
  .catch((err: any) => {
124
+ console.error("Failed to load profile", err);
91
125
  if (mounted) {
92
- setLoadError(getErrorMessage(err, "Failed to load profile"));
93
- } else {
94
- console.error("Failed to load profile", err);
126
+ setLoadError("Failed to load profile");
95
127
  }
96
128
  });
97
129
  return () => {
@@ -106,9 +138,7 @@ export default function Profile() {
106
138
  }
107
139
  }, [profile, form]);
108
140
 
109
- if (!profile && !loadError) {
110
- return <CardSkeleton contentMaxWidth="md" loadingText="Loading profile…" />;
111
- }
141
+ const loading = !profile && !loadError;
112
142
 
113
143
  return (
114
144
  <CenteredPageLayout contentMaxWidth="md" title={ROUTES.PROFILE.TITLE}>
@@ -116,44 +146,51 @@ export default function Profile() {
116
146
  <AuthForm
117
147
  title="Profile"
118
148
  description="Update your account information"
149
+ showAlreadyLoggedIn={false}
119
150
  error={loadError ?? submitError}
120
151
  success={success && "Profile updated!"}
121
- submit={{ text: "Save Changes", loadingText: "Saving…" }}
152
+ submit={{ text: "Save Changes", loadingText: "Saving…", disabled: loading }}
122
153
  footer={{ link: ROUTES.CHANGE_PASSWORD.PATH, linkText: "Change password" }}
123
154
  >
124
- <form.AppField name="Email">
125
- {(field) => <field.EmailField label="Email" disabled />}
126
- </form.AppField>
127
- <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 items-start">
128
- <form.AppField name="FirstName">
129
- {(field) => <field.TextField label="First name" autoComplete="given-name" />}
130
- </form.AppField>
131
- <form.AppField name="LastName">
132
- {(field) => <field.TextField label="Last name" autoComplete="family-name" />}
133
- </form.AppField>
134
- </div>
135
- <form.AppField name="Phone">
136
- {(field) => <field.TextField label="Phone" type="tel" autoComplete="tel" />}
137
- </form.AppField>
138
- <form.AppField name="Street">
139
- {(field) => <field.TextField label="Street" autoComplete="street-address" />}
140
- </form.AppField>
141
- <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 items-start">
142
- <form.AppField name="City">
143
- {(field) => <field.TextField label="City" autoComplete="address-level2" />}
144
- </form.AppField>
145
- <form.AppField name="State">
146
- {(field) => <field.TextField label="State" autoComplete="address-level1" />}
147
- </form.AppField>
148
- </div>
149
- <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 items-start">
150
- <form.AppField name="PostalCode">
151
- {(field) => <field.TextField label="Postal Code" autoComplete="postal-code" />}
152
- </form.AppField>
153
- <form.AppField name="Country">
154
- {(field) => <field.TextField label="Country" autoComplete="country-name" />}
155
- </form.AppField>
156
- </div>
155
+ {loading ? (
156
+ <ProfileFieldsSkeleton />
157
+ ) : (
158
+ <>
159
+ <form.AppField name="Email">
160
+ {(field) => <field.EmailField label="Email" disabled />}
161
+ </form.AppField>
162
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 items-start">
163
+ <form.AppField name="FirstName">
164
+ {(field) => <field.TextField label="First name" autoComplete="given-name" />}
165
+ </form.AppField>
166
+ <form.AppField name="LastName">
167
+ {(field) => <field.TextField label="Last name" autoComplete="family-name" />}
168
+ </form.AppField>
169
+ </div>
170
+ <form.AppField name="Phone">
171
+ {(field) => <field.TextField label="Phone" type="tel" autoComplete="tel" />}
172
+ </form.AppField>
173
+ <form.AppField name="Street">
174
+ {(field) => <field.TextField label="Street" autoComplete="street-address" />}
175
+ </form.AppField>
176
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 items-start">
177
+ <form.AppField name="City">
178
+ {(field) => <field.TextField label="City" autoComplete="address-level2" />}
179
+ </form.AppField>
180
+ <form.AppField name="State">
181
+ {(field) => <field.TextField label="State" autoComplete="address-level1" />}
182
+ </form.AppField>
183
+ </div>
184
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 items-start">
185
+ <form.AppField name="PostalCode">
186
+ {(field) => <field.TextField label="Postal Code" autoComplete="postal-code" />}
187
+ </form.AppField>
188
+ <form.AppField name="Country">
189
+ {(field) => <field.TextField label="Country" autoComplete="country-name" />}
190
+ </form.AppField>
191
+ </div>
192
+ </>
193
+ )}
157
194
  </AuthForm>
158
195
  </form.AppForm>
159
196
  </CenteredPageLayout>
@@ -7,7 +7,7 @@ import { useAppForm } from "../hooks/form";
7
7
  import { createDataSDK } from "@salesforce/platform-sdk-data";
8
8
  import { ROUTES, AUTH_PLACEHOLDERS } from "../authenticationConfig";
9
9
  import { emailSchema, passwordSchema, getStartUrl, type AuthResponse } from "../authHelpers";
10
- import { handleApiResponse, getErrorMessage } from "../utils/helpers";
10
+ import { ApiError, handleApiResponse } from "../utils/helpers";
11
11
 
12
12
  const registerSchema = z
13
13
  .object({
@@ -26,7 +26,7 @@ const registerSchema = z
26
26
  export default function Register() {
27
27
  const navigate = useNavigate();
28
28
  const [searchParams] = useSearchParams();
29
- const [submitError, setSubmitError] = useState<string | null>(null);
29
+ const [submitError, setSubmitError] = useState<React.ReactNode>(null);
30
30
 
31
31
  const form = useAppForm({
32
32
  defaultValues: {
@@ -56,7 +56,7 @@ export default function Register() {
56
56
  Accept: "application/json",
57
57
  },
58
58
  });
59
- const result = await handleApiResponse<AuthResponse>(response, "Registration failed");
59
+ const result = await handleApiResponse<AuthResponse>(response);
60
60
  if (result?.redirectUrl) {
61
61
  // Hard navigate to the URL which logs the new user in
62
62
  window.location.replace(result.redirectUrl);
@@ -65,7 +65,18 @@ export default function Register() {
65
65
  navigate(ROUTES.LOGIN.PATH, { replace: true });
66
66
  }
67
67
  } catch (err) {
68
- setSubmitError(getErrorMessage(err, "Registration failed"));
68
+ console.error("Registration failed", err);
69
+ if (err instanceof ApiError) {
70
+ setSubmitError(
71
+ <ul>
72
+ {err.errors.map((e, i) => (
73
+ <li key={i}>{e}</li>
74
+ ))}
75
+ </ul>,
76
+ );
77
+ } else {
78
+ setSubmitError("Registration failed");
79
+ }
69
80
  }
70
81
  },
71
82
  onSubmitInvalid: () => {},