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:
T. Narantuya 2025-09-19 17:50:42 +09:00
parent f662c3eb45
commit afa201c4e5
14 changed files with 83 additions and 200 deletions

View File

@ -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}>

View File

@ -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>

View File

@ -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,

View File

@ -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">

View File

@ -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 };

View File

@ -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

View File

@ -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>

View File

@ -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"
/> />

View File

@ -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>
); );

View File

@ -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";

View File

@ -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

View File

@ -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";

View File

@ -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>

View File

@ -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;