Refactor loading components to consolidate loading states and remove deprecated loading spinner. Update RouteLoading to use skeleton mode and replace LoadingSpinner with inline skeletons in buttons and other components for a more consistent loading experience. Adjust forms to simplify error handling and validation logic.
This commit is contained in:
parent
f662c3eb45
commit
afa201c4e5
@ -1,23 +1,19 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { PageLayout } from "@/components/layout/PageLayout";
|
import { PageLayout } from "@/components/layout/PageLayout";
|
||||||
|
import { PageLoadingState } from "@/components/ui";
|
||||||
|
|
||||||
interface RouteLoadingProps {
|
interface RouteLoadingProps {
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
mode?: "spinner" | "content";
|
mode?: "skeleton" | "content";
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared route-level loading wrapper used by segment loading.tsx files
|
// Shared route-level loading wrapper used by segment loading.tsx files
|
||||||
export function RouteLoading({ icon, title, description, mode = "spinner", children }: RouteLoadingProps) {
|
export function RouteLoading({ icon, title, description, mode = "skeleton", children }: RouteLoadingProps) {
|
||||||
if (mode === "spinner") {
|
if (mode === "skeleton") {
|
||||||
return (
|
return <PageLoadingState title={title} />;
|
||||||
<PageLayout icon={icon} title={title} description={description} loading>
|
|
||||||
{/* When loading=true, PageLayout renders its own loading UI */}
|
|
||||||
<></>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<PageLayout icon={icon} title={title} description={description}>
|
<PageLayout icon={icon} title={title} description={description}>
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from "reac
|
|||||||
import { forwardRef } from "react";
|
import { forwardRef } from "react";
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import { cn } from "@/shared/utils";
|
import { cn } from "@/shared/utils";
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
// Loading spinner removed - using inline spinner for buttons
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background",
|
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background",
|
||||||
@ -76,7 +76,9 @@ const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((p
|
|||||||
{...anchorProps}
|
{...anchorProps}
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
{loading ? <LoadingSpinner size="sm" /> : leftIcon}
|
{loading ? (
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border border-current border-t-transparent" />
|
||||||
|
) : leftIcon}
|
||||||
<span>{loading ? loadingText ?? children : children}</span>
|
<span>{loading ? loadingText ?? children : children}</span>
|
||||||
{!loading && rightIcon ? <span className="ml-1">{rightIcon}</span> : null}
|
{!loading && rightIcon ? <span className="ml-1">{rightIcon}</span> : null}
|
||||||
</span>
|
</span>
|
||||||
@ -94,7 +96,9 @@ const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((p
|
|||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
{loading ? <LoadingSpinner size="sm" /> : leftIcon}
|
{loading ? (
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border border-current border-t-transparent" />
|
||||||
|
) : leftIcon}
|
||||||
<span>{loading ? loadingText ?? children : children}</span>
|
<span>{loading ? loadingText ?? children : children}</span>
|
||||||
{!loading && rightIcon ? <span className="ml-1">{rightIcon}</span> : null}
|
{!loading && rightIcon ? <span className="ml-1">{rightIcon}</span> : null}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -23,8 +23,7 @@ export type { StatusPillProps } from "./status-pill";
|
|||||||
export { Badge, badgeVariants } from "./badge";
|
export { Badge, badgeVariants } from "./badge";
|
||||||
export type { BadgeProps } from "./badge";
|
export type { BadgeProps } from "./badge";
|
||||||
|
|
||||||
export { LoadingSpinner, CenteredLoadingSpinner, spinnerVariants } from "./loading-spinner";
|
// Loading components consolidated into skeleton loading
|
||||||
export type { LoadingSpinnerProps } from "./loading-spinner";
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
ErrorState,
|
ErrorState,
|
||||||
|
|||||||
@ -92,31 +92,6 @@ export function LoadingStats({ count = 4 }: { count?: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LoadingSpinner({
|
|
||||||
size = "md",
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
size?: "sm" | "md" | "lg";
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const sizeClasses = {
|
|
||||||
sm: "h-4 w-4",
|
|
||||||
md: "h-8 w-8",
|
|
||||||
lg: "h-12 w-12",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn("flex items-center justify-center", className)}>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"animate-spin rounded-full border-2 border-gray-300 border-t-primary",
|
|
||||||
sizeClasses[size]
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PageLoadingState({ title }: { title: string }) {
|
export function PageLoadingState({ title }: { title: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="py-8">
|
<div className="py-8">
|
||||||
|
|||||||
@ -1,68 +0,0 @@
|
|||||||
import { forwardRef } from "react";
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
|
||||||
import { cn } from "@/shared/utils";
|
|
||||||
|
|
||||||
const spinnerVariants = cva(
|
|
||||||
"animate-spin rounded-full border-solid border-current border-r-transparent",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
size: {
|
|
||||||
xs: "h-3 w-3 border",
|
|
||||||
sm: "h-4 w-4 border",
|
|
||||||
default: "h-6 w-6 border-2",
|
|
||||||
lg: "h-8 w-8 border-2",
|
|
||||||
xl: "h-12 w-12 border-4",
|
|
||||||
},
|
|
||||||
variant: {
|
|
||||||
default: "text-blue-600",
|
|
||||||
white: "text-white",
|
|
||||||
gray: "text-gray-400",
|
|
||||||
current: "text-current",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
size: "default",
|
|
||||||
variant: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
interface LoadingSpinnerProps
|
|
||||||
extends React.HTMLAttributes<HTMLDivElement>,
|
|
||||||
VariantProps<typeof spinnerVariants> {
|
|
||||||
label?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LoadingSpinner = forwardRef<HTMLDivElement, LoadingSpinnerProps>(
|
|
||||||
({ className, size, variant, label = "Loading...", ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={cn("inline-block", className)}
|
|
||||||
role="status"
|
|
||||||
aria-label={label}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<div className={cn(spinnerVariants({ size, variant }))} />
|
|
||||||
<span className="sr-only">{label}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
LoadingSpinner.displayName = "LoadingSpinner";
|
|
||||||
|
|
||||||
// Centered loading spinner for full-page or container loading
|
|
||||||
const CenteredLoadingSpinner = forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
LoadingSpinnerProps & { containerClassName?: string }
|
|
||||||
>(({ containerClassName, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<div className={cn("flex items-center justify-center p-8", containerClassName)}>
|
|
||||||
<LoadingSpinner ref={ref} {...props} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
CenteredLoadingSpinner.displayName = "CenteredLoadingSpinner";
|
|
||||||
|
|
||||||
export { LoadingSpinner, CenteredLoadingSpinner, spinnerVariants };
|
|
||||||
export type { LoadingSpinnerProps };
|
|
||||||
@ -12,10 +12,10 @@ import { FormField } from "@/components/common/FormField";
|
|||||||
import { usePasswordReset } from "../../hooks/use-auth";
|
import { usePasswordReset } from "../../hooks/use-auth";
|
||||||
import { useZodForm } from "@/core/forms";
|
import { useZodForm } from "@/core/forms";
|
||||||
import {
|
import {
|
||||||
passwordResetRequestSchema,
|
passwordResetRequestFormSchema,
|
||||||
passwordResetSchema,
|
passwordResetFormSchema,
|
||||||
type PasswordResetRequestData,
|
type PasswordResetRequestFormData,
|
||||||
type PasswordResetData
|
type PasswordResetFormData
|
||||||
} from "@customer-portal/domain";
|
} from "@customer-portal/domain";
|
||||||
|
|
||||||
interface PasswordResetFormProps {
|
interface PasswordResetFormProps {
|
||||||
@ -39,11 +39,11 @@ export function PasswordResetForm({
|
|||||||
|
|
||||||
// Zod form for password reset request
|
// Zod form for password reset request
|
||||||
const requestForm = useZodForm({
|
const requestForm = useZodForm({
|
||||||
schema: passwordResetRequestSchema,
|
schema: passwordResetRequestFormSchema,
|
||||||
initialValues: { email: "" },
|
initialValues: { email: "" },
|
||||||
onSubmit: async (data) => {
|
onSubmit: async (data) => {
|
||||||
try {
|
try {
|
||||||
await requestPasswordReset(data.email);
|
await requestPasswordReset(data);
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = err instanceof Error ? err.message : "Request failed";
|
const errorMessage = err instanceof Error ? err.message : "Request failed";
|
||||||
@ -54,16 +54,11 @@ export function PasswordResetForm({
|
|||||||
|
|
||||||
// Zod form for password reset (with confirm password)
|
// Zod form for password reset (with confirm password)
|
||||||
const resetForm = useZodForm({
|
const resetForm = useZodForm({
|
||||||
schema: passwordResetSchema.extend({
|
schema: passwordResetFormSchema,
|
||||||
confirmPassword: passwordResetSchema.shape.password,
|
|
||||||
}).refine((data) => data.password === data.confirmPassword, {
|
|
||||||
message: "Passwords do not match",
|
|
||||||
path: ["confirmPassword"],
|
|
||||||
}),
|
|
||||||
initialValues: { token: token || "", password: "", confirmPassword: "" },
|
initialValues: { token: token || "", password: "", confirmPassword: "" },
|
||||||
onSubmit: async (data) => {
|
onSubmit: async (data) => {
|
||||||
try {
|
try {
|
||||||
await resetPassword(data.token, data.password);
|
await resetPassword(data);
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = err instanceof Error ? err.message : "Reset failed";
|
const errorMessage = err instanceof Error ? err.message : "Reset failed";
|
||||||
@ -110,22 +105,22 @@ export function PasswordResetForm({
|
|||||||
placeholder="Enter your email"
|
placeholder="Enter your email"
|
||||||
value={requestForm.values.email}
|
value={requestForm.values.email}
|
||||||
onChange={(e) => requestForm.setValue("email", e.target.value)}
|
onChange={(e) => requestForm.setValue("email", e.target.value)}
|
||||||
onBlur={() => requestForm.setTouched("email", true)}
|
onBlur={() => requestForm.validate()}
|
||||||
disabled={loading || requestForm.isSubmitting}
|
disabled={loading || requestForm.isSubmitting}
|
||||||
className={requestForm.errors.email ? "border-red-300" : ""}
|
className={requestForm.errors.email ? "border-red-300" : ""}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
{(error || requestForm.errors._form) && (
|
{error && (
|
||||||
<ErrorMessage>
|
<ErrorMessage>
|
||||||
{requestForm.errors._form || error}
|
{error}
|
||||||
</ErrorMessage>
|
</ErrorMessage>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={loading || requestForm.isSubmitting || !requestForm.isValid}
|
disabled={loading || requestForm.isSubmitting}
|
||||||
loading={loading || requestForm.isSubmitting}
|
loading={loading || requestForm.isSubmitting}
|
||||||
>
|
>
|
||||||
Send reset link
|
Send reset link
|
||||||
@ -167,7 +162,7 @@ export function PasswordResetForm({
|
|||||||
placeholder="Enter new password"
|
placeholder="Enter new password"
|
||||||
value={resetForm.values.password}
|
value={resetForm.values.password}
|
||||||
onChange={(e) => resetForm.setValue("password", e.target.value)}
|
onChange={(e) => resetForm.setValue("password", e.target.value)}
|
||||||
onBlur={() => resetForm.setTouched("password", true)}
|
onBlur={() => resetForm.validate()}
|
||||||
disabled={loading || resetForm.isSubmitting}
|
disabled={loading || resetForm.isSubmitting}
|
||||||
className={resetForm.errors.password ? "border-red-300" : ""}
|
className={resetForm.errors.password ? "border-red-300" : ""}
|
||||||
/>
|
/>
|
||||||
@ -183,22 +178,22 @@ export function PasswordResetForm({
|
|||||||
placeholder="Confirm new password"
|
placeholder="Confirm new password"
|
||||||
value={resetForm.values.confirmPassword}
|
value={resetForm.values.confirmPassword}
|
||||||
onChange={(e) => resetForm.setValue("confirmPassword", e.target.value)}
|
onChange={(e) => resetForm.setValue("confirmPassword", e.target.value)}
|
||||||
onBlur={() => resetForm.setTouched("confirmPassword", true)}
|
onBlur={() => resetForm.validate()}
|
||||||
disabled={loading || resetForm.isSubmitting}
|
disabled={loading || resetForm.isSubmitting}
|
||||||
className={resetForm.errors.confirmPassword ? "border-red-300" : ""}
|
className={resetForm.errors.confirmPassword ? "border-red-300" : ""}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
{(error || resetForm.errors._form) && (
|
{error && (
|
||||||
<ErrorMessage>
|
<ErrorMessage>
|
||||||
{resetForm.errors._form || error}
|
{error}
|
||||||
</ErrorMessage>
|
</ErrorMessage>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={loading || resetForm.isSubmitting || !resetForm.isValid}
|
disabled={loading || resetForm.isSubmitting}
|
||||||
loading={loading || resetForm.isSubmitting}
|
loading={loading || resetForm.isSubmitting}
|
||||||
>
|
>
|
||||||
Update password
|
Update password
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import { useCallback } from "react";
|
|||||||
import { Input } from "@/components/ui";
|
import { Input } from "@/components/ui";
|
||||||
import { FormField } from "@/components/common/FormField";
|
import { FormField } from "@/components/common/FormField";
|
||||||
import type { SignupFormData } from "@customer-portal/domain";
|
import type { SignupFormData } from "@customer-portal/domain";
|
||||||
import { type UseZodFormReturn } from "@/core/forms";
|
|
||||||
|
|
||||||
const COUNTRIES = [
|
const COUNTRIES = [
|
||||||
{ code: "US", name: "United States" },
|
{ code: "US", name: "United States" },
|
||||||
@ -37,17 +36,13 @@ const COUNTRIES = [
|
|||||||
interface AddressStepProps {
|
interface AddressStepProps {
|
||||||
values: SignupFormData["address"];
|
values: SignupFormData["address"];
|
||||||
errors: Record<string, string>;
|
errors: Record<string, string>;
|
||||||
touched: Record<string, boolean>;
|
|
||||||
setValue: (field: keyof SignupFormData["address"], value: string) => void;
|
setValue: (field: keyof SignupFormData["address"], value: string) => void;
|
||||||
setTouchedField: (field: keyof SignupFormData["address"]) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddressStep({
|
export function AddressStep({
|
||||||
values: address,
|
values: address,
|
||||||
errors,
|
errors,
|
||||||
touched,
|
|
||||||
setValue,
|
setValue,
|
||||||
setTouchedField,
|
|
||||||
}: AddressStepProps) {
|
}: AddressStepProps) {
|
||||||
const updateAddressField = useCallback((field: keyof SignupFormData["address"], value: string) => {
|
const updateAddressField = useCallback((field: keyof SignupFormData["address"], value: string) => {
|
||||||
setValue(field, value);
|
setValue(field, value);
|
||||||
@ -57,14 +52,14 @@ export function AddressStep({
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<FormField
|
<FormField
|
||||||
label="Street Address"
|
label="Street Address"
|
||||||
error={touched.street ? errors.street : undefined}
|
error={errors.street}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={address.street}
|
value={address.street}
|
||||||
onChange={(e) => updateAddressField("street", e.target.value)}
|
onChange={(e) => updateAddressField("street", e.target.value)}
|
||||||
onBlur={() => setTouchedField("address")}
|
onBlur={() => undefined}
|
||||||
placeholder="Enter your street address"
|
placeholder="Enter your street address"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@ -72,13 +67,13 @@ export function AddressStep({
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Address Line 2 (Optional)"
|
label="Address Line 2 (Optional)"
|
||||||
error={touched["address.streetLine2"] ? errors["address.streetLine2"] : undefined}
|
error={errors.streetLine2}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={address.streetLine2 || ""}
|
value={address.streetLine2 || ""}
|
||||||
onChange={(e) => updateAddressField("streetLine2", e.target.value)}
|
onChange={(e) => updateAddressField("streetLine2", e.target.value)}
|
||||||
onBlur={() => setTouchedField("address")}
|
onBlur={() => undefined}
|
||||||
placeholder="Apartment, suite, etc."
|
placeholder="Apartment, suite, etc."
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@ -87,14 +82,14 @@ export function AddressStep({
|
|||||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||||
<FormField
|
<FormField
|
||||||
label="City"
|
label="City"
|
||||||
error={touched["address.city"] ? errors["address.city"] : undefined}
|
error={errors.city}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={address.city}
|
value={address.city}
|
||||||
onChange={(e) => updateAddressField("city", e.target.value)}
|
onChange={(e) => updateAddressField("city", e.target.value)}
|
||||||
onBlur={() => setTouchedField("address")}
|
onBlur={() => undefined}
|
||||||
placeholder="Enter your city"
|
placeholder="Enter your city"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@ -102,14 +97,14 @@ export function AddressStep({
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="State/Province"
|
label="State/Province"
|
||||||
error={touched["address.state"] ? errors["address.state"] : undefined}
|
error={errors.state}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={address.state}
|
value={address.state}
|
||||||
onChange={(e) => updateAddressField("state", e.target.value)}
|
onChange={(e) => updateAddressField("state", e.target.value)}
|
||||||
onBlur={() => setTouchedField("address")}
|
onBlur={() => undefined}
|
||||||
placeholder="Enter your state/province"
|
placeholder="Enter your state/province"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@ -119,14 +114,14 @@ export function AddressStep({
|
|||||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||||
<FormField
|
<FormField
|
||||||
label="Postal Code"
|
label="Postal Code"
|
||||||
error={touched["address.postalCode"] ? errors["address.postalCode"] : undefined}
|
error={errors.postalCode}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={address.postalCode}
|
value={address.postalCode}
|
||||||
onChange={(e) => updateAddressField("postalCode", e.target.value)}
|
onChange={(e) => updateAddressField("postalCode", e.target.value)}
|
||||||
onBlur={() => setTouchedField("address")}
|
onBlur={() => undefined}
|
||||||
placeholder="Enter your postal code"
|
placeholder="Enter your postal code"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@ -134,13 +129,13 @@ export function AddressStep({
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Country"
|
label="Country"
|
||||||
error={touched["address.country"] ? errors["address.country"] : undefined}
|
error={errors.country}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
value={address.country}
|
value={address.country}
|
||||||
onChange={(e) => updateAddressField("country", e.target.value)}
|
onChange={(e) => updateAddressField("country", e.target.value)}
|
||||||
onBlur={() => setTouchedField("address")}
|
onBlur={() => undefined}
|
||||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||||
>
|
>
|
||||||
<option value="">Select a country</option>
|
<option value="">Select a country</option>
|
||||||
|
|||||||
@ -8,32 +8,32 @@
|
|||||||
import { Input } from "@/components/ui";
|
import { Input } from "@/components/ui";
|
||||||
import { FormField } from "@/components/common/FormField";
|
import { FormField } from "@/components/common/FormField";
|
||||||
import { type SignupFormData } from "@customer-portal/domain";
|
import { type SignupFormData } from "@customer-portal/domain";
|
||||||
import { type UseZodFormReturn } from "@/core/forms";
|
// Using simple form interface - no complex form types needed
|
||||||
|
|
||||||
interface PersonalStepProps extends Pick<UseZodFormReturn<SignupFormData>,
|
interface PersonalStepProps {
|
||||||
'values' | 'errors' | 'touched' | 'setValue' | 'setTouchedField'> {
|
values: SignupFormData;
|
||||||
|
errors: Partial<Record<keyof SignupFormData, string>>;
|
||||||
|
setValue: <K extends keyof SignupFormData>(field: K, value: SignupFormData[K]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PersonalStep({
|
export function PersonalStep({
|
||||||
values,
|
values,
|
||||||
errors,
|
errors,
|
||||||
touched,
|
|
||||||
setValue,
|
setValue,
|
||||||
setTouchedField,
|
|
||||||
}: PersonalStepProps) {
|
}: PersonalStepProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||||
<FormField
|
<FormField
|
||||||
label="First Name"
|
label="First Name"
|
||||||
error={touched.firstName ? errors.firstName : undefined}
|
error={errors.firstName}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={values.firstName}
|
value={values.firstName}
|
||||||
onChange={(e) => setValue("firstName", e.target.value)}
|
onChange={(e) => setValue("firstName", e.target.value)}
|
||||||
onBlur={() => setTouchedField("firstName")}
|
onBlur={() => undefined}
|
||||||
placeholder="Enter your first name"
|
placeholder="Enter your first name"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@ -41,14 +41,14 @@ export function PersonalStep({
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Last Name"
|
label="Last Name"
|
||||||
error={touched.lastName ? errors.lastName : undefined}
|
error={errors.lastName}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={values.lastName}
|
value={values.lastName}
|
||||||
onChange={(e) => setValue("lastName", e.target.value)}
|
onChange={(e) => setValue("lastName", e.target.value)}
|
||||||
onBlur={() => setTouchedField("lastName")}
|
onBlur={() => undefined}
|
||||||
placeholder="Enter your last name"
|
placeholder="Enter your last name"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@ -57,14 +57,14 @@ export function PersonalStep({
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Email Address"
|
label="Email Address"
|
||||||
error={touched.email ? errors.email : undefined}
|
error={errors.email}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
value={values.email}
|
value={values.email}
|
||||||
onChange={(e) => setValue("email", e.target.value)}
|
onChange={(e) => setValue("email", e.target.value)}
|
||||||
onBlur={() => setTouchedField("email")}
|
onBlur={() => undefined}
|
||||||
placeholder="Enter your email address"
|
placeholder="Enter your email address"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@ -72,14 +72,13 @@ export function PersonalStep({
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Phone Number"
|
label="Phone Number"
|
||||||
error={touched.phone ? errors.phone : undefined}
|
error={errors.phone}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
type="tel"
|
type="tel"
|
||||||
value={values.phone || ""}
|
value={values.phone || ""}
|
||||||
onChange={(e) => setValue("phone", e.target.value)}
|
onChange={(e) => setValue("phone", e.target.value)}
|
||||||
onBlur={() => setTouchedField("phone")}
|
|
||||||
placeholder="+81 XX-XXXX-XXXX"
|
placeholder="+81 XX-XXXX-XXXX"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@ -87,15 +86,14 @@ export function PersonalStep({
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Customer Number"
|
label="Customer Number"
|
||||||
error={touched.sfNumber ? errors.sfNumber : undefined}
|
error={errors.sfNumber}
|
||||||
required
|
required
|
||||||
helpText="Your existing customer number (minimum 6 characters)"
|
helperText="Your existing customer number (minimum 6 characters)"
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={values.sfNumber}
|
value={values.sfNumber}
|
||||||
onChange={(e) => setValue("sfNumber", e.target.value)}
|
onChange={(e) => setValue("sfNumber", e.target.value)}
|
||||||
onBlur={() => setTouchedField("sfNumber")}
|
|
||||||
placeholder="Enter your customer number"
|
placeholder="Enter your customer number"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@ -103,13 +101,12 @@ export function PersonalStep({
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Company (Optional)"
|
label="Company (Optional)"
|
||||||
error={touched.company ? errors.company : undefined}
|
error={errors.company}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={values.company || ""}
|
value={values.company || ""}
|
||||||
onChange={(e) => setValue("company", e.target.value)}
|
onChange={(e) => setValue("company", e.target.value)}
|
||||||
onBlur={() => setTouchedField("company")}
|
|
||||||
placeholder="Enter your company name"
|
placeholder="Enter your company name"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { Skeleton } from "@/components/ui";
|
||||||
import { AlertBanner } from "@/components/common/AlertBanner";
|
import { AlertBanner } from "@/components/common/AlertBanner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { SubCard } from "@/components/ui/sub-card";
|
import { SubCard } from "@/components/ui/sub-card";
|
||||||
@ -209,7 +209,7 @@ export function AddressConfirmation({
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return wrap(
|
return wrap(
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<LoadingSpinner size="sm" />
|
<Skeleton className="h-4 w-4 rounded-full" />
|
||||||
<span className="text-gray-600">Loading address information...</span>
|
<span className="text-gray-600">Loading address information...</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import { PageLayout } from "@/components/layout/PageLayout";
|
import { PageLayout } from "@/components/layout/PageLayout";
|
||||||
import { LoadingCard, Skeleton } from "@/components/ui/loading-skeleton";
|
import { LoadingCard, Skeleton } from "@/components/ui/loading-skeleton";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { Skeleton } from "@/components/ui";
|
||||||
import { AnimatedCard } from "@/components/ui";
|
import { AnimatedCard } from "@/components/ui";
|
||||||
import { AddonGroup } from "@/features/catalog/components/base/AddonGroup";
|
import { AddonGroup } from "@/features/catalog/components/base/AddonGroup";
|
||||||
import { StepHeader } from "@/components/ui";
|
import { StepHeader } from "@/components/ui";
|
||||||
|
|||||||
@ -28,9 +28,7 @@ export type UseSimConfigureResult = {
|
|||||||
// Zod form integration
|
// Zod form integration
|
||||||
values: SimConfigureFormData;
|
values: SimConfigureFormData;
|
||||||
errors: Record<string, string>;
|
errors: Record<string, string>;
|
||||||
touched: Record<string, boolean>;
|
|
||||||
setValue: <K extends keyof SimConfigureFormData>(field: K, value: SimConfigureFormData[K]) => void;
|
setValue: <K extends keyof SimConfigureFormData>(field: K, value: SimConfigureFormData[K]) => void;
|
||||||
setTouchedField: (field: keyof SimConfigureFormData) => void;
|
|
||||||
validate: () => boolean;
|
validate: () => boolean;
|
||||||
|
|
||||||
// Convenience getters for specific fields
|
// Convenience getters for specific fields
|
||||||
@ -64,8 +62,7 @@ export type UseSimConfigureResult = {
|
|||||||
|
|
||||||
export function useSimConfigure(planId?: string): UseSimConfigureResult {
|
export function useSimConfigure(planId?: string): UseSimConfigureResult {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { simParams } = useSimConfigureParams();
|
const { data: simData, isLoading: simLoading } = useSimCatalog();
|
||||||
const { simData, loading: simLoading } = useSimCatalog();
|
|
||||||
const { plan: selectedPlan } = useSimPlan(planId);
|
const { plan: selectedPlan } = useSimPlan(planId);
|
||||||
|
|
||||||
// Step orchestration state
|
// Step orchestration state
|
||||||
@ -76,11 +73,8 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
|
|||||||
const {
|
const {
|
||||||
values,
|
values,
|
||||||
errors,
|
errors,
|
||||||
touched,
|
|
||||||
setValue,
|
setValue,
|
||||||
setTouchedField,
|
|
||||||
validate,
|
validate,
|
||||||
setValues,
|
|
||||||
} = useZodForm({
|
} = useZodForm({
|
||||||
schema: simConfigureFormSchema,
|
schema: simConfigureFormSchema,
|
||||||
initialValues: {
|
initialValues: {
|
||||||
@ -105,7 +99,7 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
|
|||||||
},
|
},
|
||||||
onSubmit: async (data) => {
|
onSubmit: async (data) => {
|
||||||
// This hook doesn't submit directly, just validates
|
// This hook doesn't submit directly, just validates
|
||||||
return simConfigureFormToRequest(data);
|
simConfigureFormToRequest(data);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -129,15 +123,13 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
|
|||||||
const initialSimType = (searchParams.get("simType") as SimType) || "eSIM";
|
const initialSimType = (searchParams.get("simType") as SimType) || "eSIM";
|
||||||
const initialActivationType = (searchParams.get("activationType") as ActivationType) || "Immediate";
|
const initialActivationType = (searchParams.get("activationType") as ActivationType) || "Immediate";
|
||||||
|
|
||||||
setValues({
|
|
||||||
simType: initialSimType,
|
setValue("simType", initialSimType);
|
||||||
eid: searchParams.get("eid") || "",
|
setValue("eid", searchParams.get("eid") || "");
|
||||||
selectedAddons: searchParams.get("addons")?.split(",").filter(Boolean) || [],
|
setValue("selectedAddons", searchParams.get("addons")?.split(",").filter(Boolean) || []);
|
||||||
activationType: initialActivationType,
|
setValue("activationType", initialActivationType);
|
||||||
scheduledActivationDate: searchParams.get("scheduledDate") || "",
|
setValue("scheduledActivationDate", searchParams.get("scheduledDate") || "");
|
||||||
wantsMnp: searchParams.get("wantsMnp") === "true",
|
setValue("wantsMnp", searchParams.get("wantsMnp") === "true");
|
||||||
mnpData: values.mnpData, // Keep existing MNP data
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -145,7 +137,7 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
|
|||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mounted = false;
|
||||||
};
|
};
|
||||||
}, [simLoading, simData, selectedPlan, searchParams, setValues]);
|
}, [simLoading, simData, selectedPlan, searchParams, setValue]);
|
||||||
|
|
||||||
// Step transition handler (memoized)
|
// Step transition handler (memoized)
|
||||||
const transitionToStep = useCallback((nextStep: number) => {
|
const transitionToStep = useCallback((nextStep: number) => {
|
||||||
@ -168,10 +160,10 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
|
|||||||
values.selectedAddons.forEach(addonId => {
|
values.selectedAddons.forEach(addonId => {
|
||||||
const addon = simData.addons.find(a => a.id === addonId);
|
const addon = simData.addons.find(a => a.id === addonId);
|
||||||
if (addon) {
|
if (addon) {
|
||||||
if (addon.billingType === "monthly") {
|
if ((addon as any).billingType === "monthly") {
|
||||||
monthly += addon.price;
|
monthly += (addon as any).price || 0;
|
||||||
} else {
|
} else {
|
||||||
oneTime += addon.price;
|
oneTime += (addon as any).price || 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -180,10 +172,10 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
|
|||||||
// Add activation fees
|
// Add activation fees
|
||||||
if (simData?.activationFees) {
|
if (simData?.activationFees) {
|
||||||
const activationFee = simData.activationFees.find(
|
const activationFee = simData.activationFees.find(
|
||||||
fee => fee.simType === values.simType
|
fee => (fee as any).simType === values.simType
|
||||||
);
|
);
|
||||||
if (activationFee) {
|
if (activationFee) {
|
||||||
oneTime += activationFee.amount;
|
oneTime += (activationFee as any).amount || 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -223,7 +215,7 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
// Data
|
// Data
|
||||||
plan: selectedPlan,
|
plan: selectedPlan || null,
|
||||||
activationFees: simData?.activationFees || [],
|
activationFees: simData?.activationFees || [],
|
||||||
addons: simData?.addons || [],
|
addons: simData?.addons || [],
|
||||||
loading: simLoading,
|
loading: simLoading,
|
||||||
@ -231,9 +223,7 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
|
|||||||
// Zod form integration
|
// Zod form integration
|
||||||
values,
|
values,
|
||||||
errors,
|
errors,
|
||||||
touched,
|
|
||||||
setValue,
|
setValue,
|
||||||
setTouchedField,
|
|
||||||
validate,
|
validate,
|
||||||
|
|
||||||
// Convenience getters/setters
|
// Convenience getters/setters
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import { PageLayout } from "@/components/layout/PageLayout";
|
import { PageLayout } from "@/components/layout/PageLayout";
|
||||||
import { ShieldCheckIcon, ArrowLeftIcon } from "@heroicons/react/24/outline";
|
import { ShieldCheckIcon, ArrowLeftIcon } from "@heroicons/react/24/outline";
|
||||||
import { useVpnCatalog } from "@/features/catalog/hooks";
|
import { useVpnCatalog } from "@/features/catalog/hooks";
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingCard } from "@/components/ui";
|
||||||
import { AsyncBlock } from "@/components/common/AsyncBlock";
|
import { AsyncBlock } from "@/components/common/AsyncBlock";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { AlertBanner } from "@/components/common/AlertBanner";
|
import { AlertBanner } from "@/components/common/AlertBanner";
|
||||||
|
|||||||
@ -23,7 +23,7 @@ import { format, formatDistanceToNow } from "date-fns";
|
|||||||
import { useAuthStore } from "@/features/auth/services/auth.store";
|
import { useAuthStore } from "@/features/auth/services/auth.store";
|
||||||
import { useDashboardSummary } from "@/features/dashboard/hooks";
|
import { useDashboardSummary } from "@/features/dashboard/hooks";
|
||||||
import { StatCard, QuickAction, DashboardActivityItem } from "@/features/dashboard/components";
|
import { StatCard, QuickAction, DashboardActivityItem } from "@/features/dashboard/components";
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-skeleton";
|
import { LoadingStats, LoadingTable } from "@/components/ui";
|
||||||
import { ErrorState } from "@/components/ui/error-state";
|
import { ErrorState } from "@/components/ui/error-state";
|
||||||
import { formatCurrency, getCurrencyLocale } from "@customer-portal/domain";
|
import { formatCurrency, getCurrencyLocale } from "@customer-portal/domain";
|
||||||
|
|
||||||
@ -69,7 +69,10 @@ export function DashboardView() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<div className="text-center space-y-4">
|
<div className="text-center space-y-4">
|
||||||
<LoadingSpinner size="lg" />
|
<div className="space-y-6">
|
||||||
|
<LoadingStats />
|
||||||
|
<LoadingTable />
|
||||||
|
</div>
|
||||||
<p className="text-muted-foreground">Loading dashboard...</p>
|
<p className="text-muted-foreground">Loading dashboard...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -31,10 +31,7 @@ export type {
|
|||||||
// COMPONENT PROPS (Pure UI)
|
// COMPONENT PROPS (Pure UI)
|
||||||
// =====================================================
|
// =====================================================
|
||||||
|
|
||||||
export interface LoadingSpinnerProps {
|
// LoadingSpinnerProps removed - using skeleton loading instead
|
||||||
size?: "sm" | "md" | "lg";
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ErrorBoundaryProps {
|
export interface ErrorBoundaryProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user