From 88ca636b59d39fd0c5897f0e22a0578926b7976b Mon Sep 17 00:00:00 2001 From: barsa Date: Fri, 6 Mar 2026 16:26:17 +0900 Subject: [PATCH] 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. --- .../components/atoms/animated-container.tsx | 37 ++++++ apps/portal/src/components/atoms/index.ts | 2 +- .../PasswordResetForm/PasswordResetForm.tsx | 4 +- .../auth/views/ForgotPasswordView.tsx | 110 +++++++++++++++++- .../billing/components/PaymentMethodCard.tsx | 16 +-- .../dashboard/components/ActivityFeed.tsx | 12 +- .../dashboard/components/QuickStats.tsx | 12 +- .../dashboard/views/DashboardView.tsx | 46 ++++---- .../components/sim/SimPlansContent.tsx | 6 +- .../services/views/PublicInternetPlans.tsx | 12 +- .../subscriptions/views/SubscriptionsList.tsx | 12 +- 11 files changed, 196 insertions(+), 73 deletions(-) diff --git a/apps/portal/src/components/atoms/animated-container.tsx b/apps/portal/src/components/atoms/animated-container.tsx index 938d2943..a6a47043 100644 --- a/apps/portal/src/components/atoms/animated-container.tsx +++ b/apps/portal/src/components/atoms/animated-container.tsx @@ -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, diff --git a/apps/portal/src/components/atoms/index.ts b/apps/portal/src/components/atoms/index.ts index 77d61234..fa2d965e 100644 --- a/apps/portal/src/components/atoms/index.ts +++ b/apps/portal/src/components/atoms/index.ts @@ -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"; diff --git a/apps/portal/src/features/auth/components/PasswordResetForm/PasswordResetForm.tsx b/apps/portal/src/features/auth/components/PasswordResetForm/PasswordResetForm.tsx index fe58cce8..56b8d03b 100644 --- a/apps/portal/src/features/auth/components/PasswordResetForm/PasswordResetForm.tsx +++ b/apps/portal/src/features/auth/components/PasswordResetForm/PasswordResetForm.tsx @@ -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; diff --git a/apps/portal/src/features/auth/views/ForgotPasswordView.tsx b/apps/portal/src/features/auth/views/ForgotPasswordView.tsx index 8c989a65..b63eabcc 100644 --- a/apps/portal/src/features/auth/views/ForgotPasswordView.tsx +++ b/apps/portal/src/features/auth/views/ForgotPasswordView.tsx @@ -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(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 ( + +
+
+ +
+ +
+

Check your email

+

+ We sent a password reset link to{" "} + {email} +

+
+ +
+ {canResend ? ( + + ) : ( +

Resend available in {secondsLeft}s

+ )} + + {resendError &&

{resendError}

} +
+ +
+ + + Back to login + +
+
+
+ ); +} export function ForgotPasswordView() { + const [sentToEmail, setSentToEmail] = useState(null); + + if (sentToEmail) { + return ( + + setSentToEmail(null)} /> + + ); + } + return ( - + { + if (email) setSentToEmail(email); + }} + /> ); } diff --git a/apps/portal/src/features/billing/components/PaymentMethodCard.tsx b/apps/portal/src/features/billing/components/PaymentMethodCard.tsx index 67884c7d..a7881780 100644 --- a/apps/portal/src/features/billing/components/PaymentMethodCard.tsx +++ b/apps/portal/src/features/billing/components/PaymentMethodCard.tsx @@ -82,8 +82,8 @@ export function PaymentMethodCard({ return (
@@ -91,9 +91,9 @@ export function PaymentMethodCard({
{icon}
-

{cardDisplay}

+

{cardDisplay}

{paymentMethod.isDefault && ( -
+
Default
@@ -101,17 +101,17 @@ export function PaymentMethodCard({
- {cardBrand && {cardBrand}} + {cardBrand && {cardBrand}} {expiry && ( <> - {cardBrand && } - {expiry} + {cardBrand && } + {expiry} )}
{paymentMethod.isDefault && ( -
+
This card will be used for automatic payments
)} diff --git a/apps/portal/src/features/dashboard/components/ActivityFeed.tsx b/apps/portal/src/features/dashboard/components/ActivityFeed.tsx index 512236d7..9f107aa7 100644 --- a/apps/portal/src/features/dashboard/components/ActivityFeed.tsx +++ b/apps/portal/src/features/dashboard/components/ActivityFeed.tsx @@ -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[]; diff --git a/apps/portal/src/features/dashboard/components/QuickStats.tsx b/apps/portal/src/features/dashboard/components/QuickStats.tsx index be022529..9f3ca0be 100644 --- a/apps/portal/src/features/dashboard/components/QuickStats.tsx +++ b/apps/portal/src/features/dashboard/components/QuickStats.tsx @@ -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; diff --git a/apps/portal/src/features/dashboard/views/DashboardView.tsx b/apps/portal/src/features/dashboard/views/DashboardView.tsx index 404edeaf..526223db 100644 --- a/apps/portal/src/features/dashboard/views/DashboardView.tsx +++ b/apps/portal/src/features/dashboard/views/DashboardView.tsx @@ -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 (
- -

+
+ Welcome back, {displayName} -

+ {taskCount > 0 ? ( - {hasUrgentTask && } {taskCount === 1 ? "1 task needs attention" : `${taskCount} tasks need attention`} - + ) : ( - Everything is up to date + + Everything is up to date + )} -
+
); } diff --git a/apps/portal/src/features/services/components/sim/SimPlansContent.tsx b/apps/portal/src/features/services/components/sim/SimPlansContent.tsx index d7f649f8..7e6db066 100644 --- a/apps/portal/src/features/services/components/sim/SimPlansContent.tsx +++ b/apps/portal/src/features/services/components/sim/SimPlansContent.tsx @@ -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, diff --git a/apps/portal/src/features/services/views/PublicInternetPlans.tsx b/apps/portal/src/features/services/views/PublicInternetPlans.tsx index 8e578856..f1d4ce5e 100644 --- a/apps/portal/src/features/services/views/PublicInternetPlans.tsx +++ b/apps/portal/src/features/services/views/PublicInternetPlans.tsx @@ -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, diff --git a/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx b/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx index e1458796..ff8157b0 100644 --- a/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx +++ b/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx @@ -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[];