feat: enhance animation capabilities and refactor components for improved user experience

- Introduced new motion variants in AnimatedContainer for better animation effects across the portal.
- Updated various components to utilize the new motion variants, ensuring consistent animation behavior.
- Refactored AddressCard, ProgressIndicator, and other components for improved readability and layout.
- Made minor adjustments to conditional rendering in components for better code clarity.
- Enhanced the DashboardView and other views to leverage the new animation features, improving overall user interaction.
This commit is contained in:
barsa 2026-03-06 16:26:17 +09:00
parent 4ee9cb526b
commit 88ca636b59
11 changed files with 196 additions and 73 deletions

View File

@ -16,6 +16,16 @@ const fadeUp: Variants = {
visible: { opacity: 1, y: 0, transition: { duration: 0.3, ease: "easeOut" } },
};
const fadeUpSmall: Variants = {
hidden: { opacity: 0, y: 12 },
visible: { opacity: 1, y: 0, transition: { duration: 0.2, ease: "easeOut" } },
};
const fadeUpFast: Variants = {
hidden: { opacity: 0, y: 16 },
visible: { opacity: 1, y: 0, transition: { duration: 0.2, ease: "easeOut" } },
};
const fadeScale: Variants = {
hidden: { opacity: 0, scale: 0.95 },
visible: { opacity: 1, scale: 1, transition: { duration: 0.2, ease: "easeOut" } },
@ -26,11 +36,38 @@ const slideLeft: Variants = {
visible: { opacity: 1, x: 0, transition: { duration: 0.3, ease: "easeOut" } },
};
const slideLeftSmall: Variants = {
hidden: { opacity: 0, x: -8 },
visible: { opacity: 1, x: 0, transition: { duration: 0.2, ease: "easeOut" } },
};
const noneVariant: Variants = {
hidden: {},
visible: {},
};
/** Stagger container — wrap children that use item variants */
function staggerContainer(staggerSeconds = 0.05): Variants {
return {
hidden: {},
visible: { transition: { staggerChildren: staggerSeconds } },
};
}
/**
* Re-exported variant objects for direct use with motion components.
* Prefer these over defining local variants in each file.
*/
export const motionVariants = {
fadeUp,
fadeUpSmall,
fadeUpFast,
fadeScale,
slideLeft,
slideLeftSmall,
staggerContainer,
} as const;
const variantMap = {
"fade-up": fadeUp,
"fade-scale": fadeScale,

View File

@ -66,4 +66,4 @@ export { StatusIndicator, type StatusIndicatorStatus } from "./status-indicator"
export { ViewToggle, type ViewMode } from "./view-toggle";
// Animation
export { AnimatedContainer } from "./animated-container";
export { AnimatedContainer, motionVariants } from "./animated-container";

View File

@ -45,7 +45,7 @@ const ERROR_BORDER_CLASS = "border-red-300";
interface PasswordResetFormProps {
mode: "request" | "reset";
token?: string | undefined;
onSuccess?: (() => void) | undefined;
onSuccess?: ((email?: string) => void) | undefined;
onError?: ((error: string) => void) | undefined;
showLoginLink?: boolean | undefined;
className?: string | undefined;
@ -67,7 +67,7 @@ export function PasswordResetForm({
onSubmit: async data => {
try {
await requestPasswordReset(data.email);
onSuccess?.();
onSuccess?.(data.email);
} catch (err) {
onError?.(parseError(err).message);
throw err;

View File

@ -1,15 +1,123 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import Link from "next/link";
import { Mail } from "lucide-react";
import { AuthLayout } from "../components";
import { PasswordResetForm } from "@/features/auth/components";
import { Button, AnimatedContainer } from "@/components/atoms";
import { useAuthStore } from "../stores/auth.store";
const RESEND_COOLDOWN_SECONDS = 60;
function ResendCountdown({ email, onBackToForm }: { email: string; onBackToForm: () => void }) {
const [secondsLeft, setSecondsLeft] = useState(RESEND_COOLDOWN_SECONDS);
const [resending, setResending] = useState(false);
const [resendError, setResendError] = useState<string | null>(null);
const requestPasswordReset = useAuthStore(state => state.requestPasswordReset);
const cooldownEndRef = useRef(Date.now() + RESEND_COOLDOWN_SECONDS * 1000);
useEffect(() => {
const update = () => {
const remaining = Math.max(0, Math.ceil((cooldownEndRef.current - Date.now()) / 1000));
setSecondsLeft(remaining);
};
update();
const interval = setInterval(update, 1000);
return () => clearInterval(interval);
}, []);
const handleResend = useCallback(async () => {
setResending(true);
setResendError(null);
try {
await requestPasswordReset(email);
cooldownEndRef.current = Date.now() + RESEND_COOLDOWN_SECONDS * 1000;
setSecondsLeft(RESEND_COOLDOWN_SECONDS);
} catch {
setResendError("Failed to resend. Please try again.");
} finally {
setResending(false);
}
}, [email, requestPasswordReset]);
const canResend = secondsLeft === 0 && !resending;
return (
<AnimatedContainer animation="fade-up">
<div className="space-y-6 text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-primary/10">
<Mail className="h-7 w-7 text-primary" />
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold text-foreground">Check your email</h3>
<p className="text-sm text-muted-foreground">
We sent a password reset link to{" "}
<span className="font-medium text-foreground">{email}</span>
</p>
</div>
<div className="space-y-3">
{canResend ? (
<Button
variant="outline"
className="w-full"
onClick={() => void handleResend()}
disabled={resending}
loading={resending}
>
Resend reset link
</Button>
) : (
<p className="text-sm text-muted-foreground">Resend available in {secondsLeft}s</p>
)}
{resendError && <p className="text-sm text-red-600">{resendError}</p>}
</div>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={onBackToForm}
className="text-sm text-muted-foreground hover:text-foreground transition-colors duration-[var(--cp-duration-normal)]"
>
Try a different email
</button>
<Link
href="/auth/login"
className="text-sm text-primary hover:underline font-medium transition-colors duration-[var(--cp-duration-normal)]"
>
Back to login
</Link>
</div>
</div>
</AnimatedContainer>
);
}
export function ForgotPasswordView() {
const [sentToEmail, setSentToEmail] = useState<string | null>(null);
if (sentToEmail) {
return (
<AuthLayout title="Forgot password" subtitle="Follow the instructions in your email">
<ResendCountdown email={sentToEmail} onBackToForm={() => setSentToEmail(null)} />
</AuthLayout>
);
}
return (
<AuthLayout
title="Forgot password"
subtitle="Enter your email address and we'll send you a reset link"
>
<PasswordResetForm mode="request" />
<PasswordResetForm
mode="request"
onSuccess={email => {
if (email) setSentToEmail(email);
}}
/>
</AuthLayout>
);
}

View File

@ -82,8 +82,8 @@ export function PaymentMethodCard({
return (
<div
className={cn(
"flex items-center justify-between p-4 border border-gray-200 rounded-lg bg-white transition-all duration-200 hover:shadow-sm",
paymentMethod.isDefault && "ring-2 ring-blue-500/20 border-blue-200 bg-blue-50/30",
"flex items-center justify-between p-4 border border-border rounded-lg bg-card transition-all duration-200 hover:shadow-sm",
paymentMethod.isDefault && "ring-2 ring-primary/20 border-primary/30 bg-primary/5",
className
)}
>
@ -91,9 +91,9 @@ export function PaymentMethodCard({
<div className="flex-shrink-0">{icon}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2.5 mb-0.5">
<h3 className="font-semibold text-gray-900 text-sm font-mono">{cardDisplay}</h3>
<h3 className="font-semibold text-foreground text-sm font-mono">{cardDisplay}</h3>
{paymentMethod.isDefault && (
<div className="flex items-center gap-1 bg-blue-100 text-blue-800 text-xs font-medium px-2 py-1 rounded-full">
<div className="flex items-center gap-1 bg-primary/10 text-primary text-xs font-medium px-2 py-1 rounded-full">
<CheckCircleIcon className="h-3 w-3" />
Default
</div>
@ -101,17 +101,17 @@ export function PaymentMethodCard({
</div>
<div className="flex items-center gap-3 text-xs">
{cardBrand && <span className="text-gray-600 font-medium">{cardBrand}</span>}
{cardBrand && <span className="text-muted-foreground font-medium">{cardBrand}</span>}
{expiry && (
<>
{cardBrand && <span className="text-gray-300"></span>}
<span className="text-gray-500">{expiry}</span>
{cardBrand && <span className="text-border"></span>}
<span className="text-muted-foreground/70">{expiry}</span>
</>
)}
</div>
{paymentMethod.isDefault && (
<div className="text-xs text-blue-600 font-medium mt-1">
<div className="text-xs text-primary font-medium mt-1">
This card will be used for automatic payments
</div>
)}

View File

@ -13,16 +13,10 @@ import {
import type { Activity } from "@customer-portal/domain/dashboard";
import { formatActivityDate, getActivityNavigationPath } from "../utils/dashboard.utils";
import { cn } from "@/shared/utils";
import { motionVariants } from "@/components/atoms";
const containerVariants = {
hidden: {},
visible: { transition: { staggerChildren: 0.05 } },
};
const itemVariants = {
hidden: { opacity: 0, x: -8 },
visible: { opacity: 1, x: 0, transition: { duration: 0.2, ease: "easeOut" as const } },
};
const containerVariants = motionVariants.staggerContainer(0.05);
const itemVariants = motionVariants.slideLeftSmall;
interface ActivityFeedProps {
activities: Activity[];

View File

@ -9,16 +9,10 @@ import {
ArrowRightIcon,
} from "@heroicons/react/24/outline";
import { cn } from "@/shared/utils";
import { motionVariants } from "@/components/atoms";
const containerVariants = {
hidden: {},
visible: { transition: { staggerChildren: 0.05 } },
};
const itemVariants = {
hidden: { opacity: 0, y: 16 },
visible: { opacity: 1, y: 0, transition: { duration: 0.2, ease: "easeOut" as const } },
};
const containerVariants = motionVariants.staggerContainer(0.05);
const itemVariants = motionVariants.fadeUpFast;
interface QuickStatsProps {
activeSubscriptions: number;

View File

@ -8,20 +8,14 @@ import { useAuthStore } from "@/features/auth/stores/auth.store";
import { useDashboardSummary, useDashboardTasks } from "@/features/dashboard/hooks";
import { TaskList, QuickStats, ActivityFeed } from "@/features/dashboard/components";
import { ErrorState } from "@/components/atoms/error-state";
import { motionVariants } from "@/components/atoms";
import { PageLayout } from "@/components/templates";
import { cn } from "@/shared/utils";
import { InlineToast } from "@/components/atoms/inline-toast";
import { useInternetEligibility } from "@/features/services/hooks";
const gridContainerVariants = {
hidden: {},
visible: { transition: { staggerChildren: 0.05 } },
};
const gridItemVariants = {
hidden: { opacity: 0, y: 16 },
visible: { opacity: 1, y: 0, transition: { duration: 0.3, ease: "easeOut" as const } },
};
const gridContainerVariants = motionVariants.staggerContainer(0.05);
const gridItemVariants = motionVariants.fadeUp;
function DashboardSkeleton() {
return (
@ -65,29 +59,39 @@ function DashboardGreeting({
}) {
return (
<div className="mb-8">
<motion.div
className="flex flex-wrap items-center gap-x-3 gap-y-2"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<h2 className="text-2xl sm:text-3xl font-bold text-foreground font-heading tracking-tight">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<motion.h2
className="text-2xl sm:text-3xl font-bold text-foreground font-heading tracking-tight"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
Welcome back, {displayName}
</h2>
</motion.h2>
{taskCount > 0 ? (
<span
<motion.span
className={cn(
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium",
hasUrgentTask ? "bg-danger/10 text-danger" : "bg-warning/10 text-warning"
)}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
{hasUrgentTask && <ExclamationTriangleIcon className="h-3.5 w-3.5" />}
{taskCount === 1 ? "1 task needs attention" : `${taskCount} tasks need attention`}
</span>
</motion.span>
) : (
<span className="text-sm text-muted-foreground">Everything is up to date</span>
<motion.span
className="text-sm text-muted-foreground"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
Everything is up to date
</motion.span>
)}
</motion.div>
</div>
</div>
);
}

View File

@ -35,6 +35,7 @@ import {
import { SimHowItWorksSection } from "@/features/services/components/sim/SimHowItWorksSection";
import { AnimatePresence, motion } from "framer-motion";
import { cn } from "@/shared/utils";
import { motionVariants } from "@/components/atoms";
export type SimPlansTab = "data-voice" | "data-only" | "voice-only";
@ -311,10 +312,7 @@ function SimTabSwitcher({
);
}
const cardVariants = {
hidden: { opacity: 0, y: 16 },
visible: { opacity: 1, y: 0, transition: { duration: 0.3, ease: "easeOut" as const } },
};
const cardVariants = motionVariants.fadeUp;
function SimPlansGrid({
regularPlans,

View File

@ -39,6 +39,7 @@ import {
} from "@/features/services/utils/internet-config";
import { AnimatePresence, motion } from "framer-motion";
import { cn } from "@/shared/utils";
import { motionVariants } from "@/components/atoms";
// ─── Types ────────────────────────────────────────────────────────────────────
@ -115,17 +116,10 @@ function getOfferingTypeId(offeringType: string | undefined): string {
return "home1g";
}
const cardVariants = {
hidden: { opacity: 0, y: 16 },
visible: { opacity: 1, y: 0, transition: { duration: 0.3, ease: "easeOut" as const } },
};
const cardVariants = motionVariants.fadeUp;
const tierContainerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.08 },
},
...motionVariants.staggerContainer(0.08),
exit: {
opacity: 0,
x: -24,

View File

@ -3,6 +3,7 @@
import { useState, useMemo } from "react";
import { motion } from "framer-motion";
import { Button } from "@/components/atoms/button";
import { motionVariants } from "@/components/atoms";
import { ViewToggle, type ViewMode } from "@/components/atoms/view-toggle";
import { MetricCard, MetricCardSkeleton } from "@/components/molecules/MetricCard";
import { ErrorBoundary } from "@/components/molecules";
@ -23,15 +24,8 @@ import {
type SubscriptionStatus,
} from "@customer-portal/domain/subscriptions";
const gridContainerVariants = {
hidden: {},
visible: { transition: { staggerChildren: 0.03 } },
};
const gridItemVariants = {
hidden: { opacity: 0, y: 12 },
visible: { opacity: 1, y: 0, transition: { duration: 0.2, ease: "easeOut" as const } },
};
const gridContainerVariants = motionVariants.staggerContainer(0.03);
const gridItemVariants = motionVariants.fadeUpSmall;
const SUBSCRIPTION_STATUS_OPTIONS = Object.values(SUBSCRIPTION_STATUS) as SubscriptionStatus[];