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 { PageLayout } from "@/components/layout/PageLayout";
import { PageLoadingState } from "@/components/ui";
interface RouteLoadingProps {
icon?: ReactNode;
title: string;
description?: string;
mode?: "spinner" | "content";
mode?: "skeleton" | "content";
children?: ReactNode;
}
// Shared route-level loading wrapper used by segment loading.tsx files
export function RouteLoading({ icon, title, description, mode = "spinner", children }: RouteLoadingProps) {
if (mode === "spinner") {
return (
<PageLayout icon={icon} title={title} description={description} loading>
{/* When loading=true, PageLayout renders its own loading UI */}
<></>
</PageLayout>
);
export function RouteLoading({ icon, title, description, mode = "skeleton", children }: RouteLoadingProps) {
if (mode === "skeleton") {
return <PageLoadingState title={title} />;
}
return (
<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 { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/shared/utils";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
// Loading spinner removed - using inline spinner for buttons
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",
@ -76,7 +76,9 @@ const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((p
{...anchorProps}
>
<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>
{!loading && rightIcon ? <span className="ml-1">{rightIcon}</span> : null}
</span>
@ -94,7 +96,9 @@ const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((p
{...buttonProps}
>
<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>
{!loading && rightIcon ? <span className="ml-1">{rightIcon}</span> : null}
</span>

View File

@ -23,8 +23,7 @@ export type { StatusPillProps } from "./status-pill";
export { Badge, badgeVariants } from "./badge";
export type { BadgeProps } from "./badge";
export { LoadingSpinner, CenteredLoadingSpinner, spinnerVariants } from "./loading-spinner";
export type { LoadingSpinnerProps } from "./loading-spinner";
// Loading components consolidated into skeleton loading
export {
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 }) {
return (
<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 { useZodForm } from "@/core/forms";
import {
passwordResetRequestSchema,
passwordResetSchema,
type PasswordResetRequestData,
type PasswordResetData
passwordResetRequestFormSchema,
passwordResetFormSchema,
type PasswordResetRequestFormData,
type PasswordResetFormData
} from "@customer-portal/domain";
interface PasswordResetFormProps {
@ -39,11 +39,11 @@ export function PasswordResetForm({
// Zod form for password reset request
const requestForm = useZodForm({
schema: passwordResetRequestSchema,
schema: passwordResetRequestFormSchema,
initialValues: { email: "" },
onSubmit: async (data) => {
try {
await requestPasswordReset(data.email);
await requestPasswordReset(data);
onSuccess?.();
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Request failed";
@ -54,16 +54,11 @@ export function PasswordResetForm({
// Zod form for password reset (with confirm password)
const resetForm = useZodForm({
schema: passwordResetSchema.extend({
confirmPassword: passwordResetSchema.shape.password,
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
}),
schema: passwordResetFormSchema,
initialValues: { token: token || "", password: "", confirmPassword: "" },
onSubmit: async (data) => {
try {
await resetPassword(data.token, data.password);
await resetPassword(data);
onSuccess?.();
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Reset failed";
@ -110,22 +105,22 @@ export function PasswordResetForm({
placeholder="Enter your email"
value={requestForm.values.email}
onChange={(e) => requestForm.setValue("email", e.target.value)}
onBlur={() => requestForm.setTouched("email", true)}
onBlur={() => requestForm.validate()}
disabled={loading || requestForm.isSubmitting}
className={requestForm.errors.email ? "border-red-300" : ""}
/>
</FormField>
{(error || requestForm.errors._form) && (
{error && (
<ErrorMessage>
{requestForm.errors._form || error}
{error}
</ErrorMessage>
)}
<Button
type="submit"
className="w-full"
disabled={loading || requestForm.isSubmitting || !requestForm.isValid}
disabled={loading || requestForm.isSubmitting}
loading={loading || requestForm.isSubmitting}
>
Send reset link
@ -167,7 +162,7 @@ export function PasswordResetForm({
placeholder="Enter new password"
value={resetForm.values.password}
onChange={(e) => resetForm.setValue("password", e.target.value)}
onBlur={() => resetForm.setTouched("password", true)}
onBlur={() => resetForm.validate()}
disabled={loading || resetForm.isSubmitting}
className={resetForm.errors.password ? "border-red-300" : ""}
/>
@ -183,22 +178,22 @@ export function PasswordResetForm({
placeholder="Confirm new password"
value={resetForm.values.confirmPassword}
onChange={(e) => resetForm.setValue("confirmPassword", e.target.value)}
onBlur={() => resetForm.setTouched("confirmPassword", true)}
onBlur={() => resetForm.validate()}
disabled={loading || resetForm.isSubmitting}
className={resetForm.errors.confirmPassword ? "border-red-300" : ""}
/>
</FormField>
{(error || resetForm.errors._form) && (
{error && (
<ErrorMessage>
{resetForm.errors._form || error}
{error}
</ErrorMessage>
)}
<Button
type="submit"
className="w-full"
disabled={loading || resetForm.isSubmitting || !resetForm.isValid}
disabled={loading || resetForm.isSubmitting}
loading={loading || resetForm.isSubmitting}
>
Update password

View File

@ -9,7 +9,6 @@ import { useCallback } from "react";
import { Input } from "@/components/ui";
import { FormField } from "@/components/common/FormField";
import type { SignupFormData } from "@customer-portal/domain";
import { type UseZodFormReturn } from "@/core/forms";
const COUNTRIES = [
{ code: "US", name: "United States" },
@ -37,17 +36,13 @@ const COUNTRIES = [
interface AddressStepProps {
values: SignupFormData["address"];
errors: Record<string, string>;
touched: Record<string, boolean>;
setValue: (field: keyof SignupFormData["address"], value: string) => void;
setTouchedField: (field: keyof SignupFormData["address"]) => void;
}
export function AddressStep({
values: address,
errors,
touched,
setValue,
setTouchedField,
}: AddressStepProps) {
const updateAddressField = useCallback((field: keyof SignupFormData["address"], value: string) => {
setValue(field, value);
@ -57,14 +52,14 @@ export function AddressStep({
<div className="space-y-6">
<FormField
label="Street Address"
error={touched.street ? errors.street : undefined}
error={errors.street}
required
>
<Input
type="text"
value={address.street}
onChange={(e) => updateAddressField("street", e.target.value)}
onBlur={() => setTouchedField("address")}
onBlur={() => undefined}
placeholder="Enter your street address"
className="w-full"
/>
@ -72,13 +67,13 @@ export function AddressStep({
<FormField
label="Address Line 2 (Optional)"
error={touched["address.streetLine2"] ? errors["address.streetLine2"] : undefined}
error={errors.streetLine2}
>
<Input
type="text"
value={address.streetLine2 || ""}
onChange={(e) => updateAddressField("streetLine2", e.target.value)}
onBlur={() => setTouchedField("address")}
onBlur={() => undefined}
placeholder="Apartment, suite, etc."
className="w-full"
/>
@ -87,14 +82,14 @@ export function AddressStep({
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<FormField
label="City"
error={touched["address.city"] ? errors["address.city"] : undefined}
error={errors.city}
required
>
<Input
type="text"
value={address.city}
onChange={(e) => updateAddressField("city", e.target.value)}
onBlur={() => setTouchedField("address")}
onBlur={() => undefined}
placeholder="Enter your city"
className="w-full"
/>
@ -102,14 +97,14 @@ export function AddressStep({
<FormField
label="State/Province"
error={touched["address.state"] ? errors["address.state"] : undefined}
error={errors.state}
required
>
<Input
type="text"
value={address.state}
onChange={(e) => updateAddressField("state", e.target.value)}
onBlur={() => setTouchedField("address")}
onBlur={() => undefined}
placeholder="Enter your state/province"
className="w-full"
/>
@ -119,14 +114,14 @@ export function AddressStep({
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<FormField
label="Postal Code"
error={touched["address.postalCode"] ? errors["address.postalCode"] : undefined}
error={errors.postalCode}
required
>
<Input
type="text"
value={address.postalCode}
onChange={(e) => updateAddressField("postalCode", e.target.value)}
onBlur={() => setTouchedField("address")}
onBlur={() => undefined}
placeholder="Enter your postal code"
className="w-full"
/>
@ -134,13 +129,13 @@ export function AddressStep({
<FormField
label="Country"
error={touched["address.country"] ? errors["address.country"] : undefined}
error={errors.country}
required
>
<select
value={address.country}
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"
>
<option value="">Select a country</option>

View File

@ -8,32 +8,32 @@
import { Input } from "@/components/ui";
import { FormField } from "@/components/common/FormField";
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>,
'values' | 'errors' | 'touched' | 'setValue' | 'setTouchedField'> {
interface PersonalStepProps {
values: SignupFormData;
errors: Partial<Record<keyof SignupFormData, string>>;
setValue: <K extends keyof SignupFormData>(field: K, value: SignupFormData[K]) => void;
}
export function PersonalStep({
values,
errors,
touched,
setValue,
setTouchedField,
}: PersonalStepProps) {
return (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<FormField
label="First Name"
error={touched.firstName ? errors.firstName : undefined}
error={errors.firstName}
required
>
<Input
type="text"
value={values.firstName}
onChange={(e) => setValue("firstName", e.target.value)}
onBlur={() => setTouchedField("firstName")}
onBlur={() => undefined}
placeholder="Enter your first name"
className="w-full"
/>
@ -41,14 +41,14 @@ export function PersonalStep({
<FormField
label="Last Name"
error={touched.lastName ? errors.lastName : undefined}
error={errors.lastName}
required
>
<Input
type="text"
value={values.lastName}
onChange={(e) => setValue("lastName", e.target.value)}
onBlur={() => setTouchedField("lastName")}
onBlur={() => undefined}
placeholder="Enter your last name"
className="w-full"
/>
@ -57,14 +57,14 @@ export function PersonalStep({
<FormField
label="Email Address"
error={touched.email ? errors.email : undefined}
error={errors.email}
required
>
<Input
type="email"
value={values.email}
onChange={(e) => setValue("email", e.target.value)}
onBlur={() => setTouchedField("email")}
onBlur={() => undefined}
placeholder="Enter your email address"
className="w-full"
/>
@ -72,14 +72,13 @@ export function PersonalStep({
<FormField
label="Phone Number"
error={touched.phone ? errors.phone : undefined}
error={errors.phone}
required
>
<Input
type="tel"
value={values.phone || ""}
onChange={(e) => setValue("phone", e.target.value)}
onBlur={() => setTouchedField("phone")}
placeholder="+81 XX-XXXX-XXXX"
className="w-full"
/>
@ -87,15 +86,14 @@ export function PersonalStep({
<FormField
label="Customer Number"
error={touched.sfNumber ? errors.sfNumber : undefined}
error={errors.sfNumber}
required
helpText="Your existing customer number (minimum 6 characters)"
helperText="Your existing customer number (minimum 6 characters)"
>
<Input
type="text"
value={values.sfNumber}
onChange={(e) => setValue("sfNumber", e.target.value)}
onBlur={() => setTouchedField("sfNumber")}
placeholder="Enter your customer number"
className="w-full"
/>
@ -103,13 +101,12 @@ export function PersonalStep({
<FormField
label="Company (Optional)"
error={touched.company ? errors.company : undefined}
error={errors.company}
>
<Input
type="text"
value={values.company || ""}
onChange={(e) => setValue("company", e.target.value)}
onBlur={() => setTouchedField("company")}
placeholder="Enter your company name"
className="w-full"
/>

View File

@ -1,6 +1,6 @@
"use client";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Skeleton } from "@/components/ui";
import { AlertBanner } from "@/components/common/AlertBanner";
import { Button } from "@/components/ui/button";
import { SubCard } from "@/components/ui/sub-card";
@ -209,7 +209,7 @@ export function AddressConfirmation({
if (loading) {
return wrap(
<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>
</div>
);

View File

@ -3,7 +3,7 @@
import { PageLayout } from "@/components/layout/PageLayout";
import { LoadingCard, Skeleton } from "@/components/ui/loading-skeleton";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Skeleton } from "@/components/ui";
import { AnimatedCard } from "@/components/ui";
import { AddonGroup } from "@/features/catalog/components/base/AddonGroup";
import { StepHeader } from "@/components/ui";

View File

@ -28,9 +28,7 @@ export type UseSimConfigureResult = {
// Zod form integration
values: SimConfigureFormData;
errors: Record<string, string>;
touched: Record<string, boolean>;
setValue: <K extends keyof SimConfigureFormData>(field: K, value: SimConfigureFormData[K]) => void;
setTouchedField: (field: keyof SimConfigureFormData) => void;
validate: () => boolean;
// Convenience getters for specific fields
@ -64,8 +62,7 @@ export type UseSimConfigureResult = {
export function useSimConfigure(planId?: string): UseSimConfigureResult {
const searchParams = useSearchParams();
const { simParams } = useSimConfigureParams();
const { simData, loading: simLoading } = useSimCatalog();
const { data: simData, isLoading: simLoading } = useSimCatalog();
const { plan: selectedPlan } = useSimPlan(planId);
// Step orchestration state
@ -76,11 +73,8 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
const {
values,
errors,
touched,
setValue,
setTouchedField,
validate,
setValues,
} = useZodForm({
schema: simConfigureFormSchema,
initialValues: {
@ -105,7 +99,7 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
},
onSubmit: async (data) => {
// 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 initialActivationType = (searchParams.get("activationType") as ActivationType) || "Immediate";
setValues({
simType: initialSimType,
eid: searchParams.get("eid") || "",
selectedAddons: searchParams.get("addons")?.split(",").filter(Boolean) || [],
activationType: initialActivationType,
scheduledActivationDate: searchParams.get("scheduledDate") || "",
wantsMnp: searchParams.get("wantsMnp") === "true",
mnpData: values.mnpData, // Keep existing MNP data
});
setValue("simType", initialSimType);
setValue("eid", searchParams.get("eid") || "");
setValue("selectedAddons", searchParams.get("addons")?.split(",").filter(Boolean) || []);
setValue("activationType", initialActivationType);
setValue("scheduledActivationDate", searchParams.get("scheduledDate") || "");
setValue("wantsMnp", searchParams.get("wantsMnp") === "true");
}
}
@ -145,7 +137,7 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
return () => {
mounted = false;
};
}, [simLoading, simData, selectedPlan, searchParams, setValues]);
}, [simLoading, simData, selectedPlan, searchParams, setValue]);
// Step transition handler (memoized)
const transitionToStep = useCallback((nextStep: number) => {
@ -168,10 +160,10 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
values.selectedAddons.forEach(addonId => {
const addon = simData.addons.find(a => a.id === addonId);
if (addon) {
if (addon.billingType === "monthly") {
monthly += addon.price;
if ((addon as any).billingType === "monthly") {
monthly += (addon as any).price || 0;
} else {
oneTime += addon.price;
oneTime += (addon as any).price || 0;
}
}
});
@ -180,10 +172,10 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
// Add activation fees
if (simData?.activationFees) {
const activationFee = simData.activationFees.find(
fee => fee.simType === values.simType
fee => (fee as any).simType === values.simType
);
if (activationFee) {
oneTime += activationFee.amount;
oneTime += (activationFee as any).amount || 0;
}
}
@ -223,7 +215,7 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
return {
// Data
plan: selectedPlan,
plan: selectedPlan || null,
activationFees: simData?.activationFees || [],
addons: simData?.addons || [],
loading: simLoading,
@ -231,9 +223,7 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
// Zod form integration
values,
errors,
touched,
setValue,
setTouchedField,
validate,
// Convenience getters/setters

View File

@ -3,7 +3,7 @@
import { PageLayout } from "@/components/layout/PageLayout";
import { ShieldCheckIcon, ArrowLeftIcon } from "@heroicons/react/24/outline";
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 { Button } from "@/components/ui/button";
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 { useDashboardSummary } from "@/features/dashboard/hooks";
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 { formatCurrency, getCurrencyLocale } from "@customer-portal/domain";
@ -69,7 +69,10 @@ export function DashboardView() {
return (
<div className="flex items-center justify-center h-64">
<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>
</div>
</div>

View File

@ -31,10 +31,7 @@ export type {
// COMPONENT PROPS (Pure UI)
// =====================================================
export interface LoadingSpinnerProps {
size?: "sm" | "md" | "lg";
className?: string;
}
// LoadingSpinnerProps removed - using skeleton loading instead
export interface ErrorBoundaryProps {
children: React.ReactNode;