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:
parent
4ee9cb526b
commit
88ca636b59
@ -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,
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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[];
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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[];
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user