feat: implement portaled NotificationBell in AppShell for improved user experience
- Introduced PortaledNotificationBell component to render the NotificationBell in the PageLayout header, ensuring it remains persistent across page navigations. - Updated AppShell to utilize the new portaled NotificationBell, enhancing the layout and user interaction. - Adjusted PageLayout to accommodate the new notification rendering logic, improving overall UI consistency. - Made minor adjustments to various components for better alignment and spacing.
This commit is contained in:
parent
7502068ea9
commit
4ee9cb526b
@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { useAuthStore, useAuthSession } from "@/features/auth/stores/auth.store";
|
import { useAuthStore, useAuthSession } from "@/features/auth/stores/auth.store";
|
||||||
@ -125,6 +126,29 @@ function useSidebarExpansion(pathname: string) {
|
|||||||
return { expandedItems, toggleExpanded };
|
return { expandedItems, toggleExpanded };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Portals the NotificationBell into the PageLayout header slot (#page-header-end).
|
||||||
|
* Lives in AppShell so it never remounts on page navigation, preserving polling timers.
|
||||||
|
* Re-targets when the DOM element changes (page navigation causes PageLayout remount).
|
||||||
|
*/
|
||||||
|
function PortaledNotificationBell() {
|
||||||
|
const [target, setTarget] = useState<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sync = () => {
|
||||||
|
const el = document.getElementById("page-header-end");
|
||||||
|
setTarget(prev => (prev === el ? prev : el));
|
||||||
|
};
|
||||||
|
sync();
|
||||||
|
const observer = new MutationObserver(sync);
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!target) return null;
|
||||||
|
return createPortal(<NotificationBell />, target);
|
||||||
|
}
|
||||||
|
|
||||||
function AuthLoadingSkeleton() {
|
function AuthLoadingSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="p-6 md:p-8 lg:p-10">
|
<div className="p-6 md:p-8 lg:p-10">
|
||||||
@ -231,22 +255,19 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div className="flex flex-col w-0 flex-1 overflow-hidden bg-background">
|
<div className="flex flex-col w-0 flex-1 overflow-hidden bg-background">
|
||||||
{/* Header bar */}
|
{/* Mobile-only hamburger bar */}
|
||||||
<div className="flex items-center h-16 px-3 md:px-6 border-b border-border/40 bg-background flex-shrink-0">
|
<div className="md:hidden flex items-center h-16 px-3 border-b border-border/40 bg-background">
|
||||||
{/* Mobile hamburger + logo */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="md:hidden flex items-center justify-center w-10 h-10 -ml-1 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/60 active:bg-muted transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary/20"
|
className="flex items-center justify-center w-10 h-10 -ml-1 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/60 active:bg-muted transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||||
onClick={() => setSidebarOpen(true)}
|
onClick={() => setSidebarOpen(true)}
|
||||||
aria-label="Open navigation"
|
aria-label="Open navigation"
|
||||||
>
|
>
|
||||||
<Bars3Icon className="h-5 w-5" />
|
<Bars3Icon className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
<div className="md:hidden ml-2">
|
<div className="ml-2">
|
||||||
<Logo size={20} />
|
<Logo size={20} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1" />
|
|
||||||
<NotificationBell />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main content area */}
|
{/* Main content area */}
|
||||||
@ -257,7 +278,8 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Global utilities are mounted in RootLayout */}
|
{/* Persistent notification bell — portaled into PageLayout header */}
|
||||||
|
{isAuthReady && <PortaledNotificationBell />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,48 +33,34 @@ export function PageLayout({
|
|||||||
}: PageLayoutProps) {
|
}: PageLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Page header */}
|
{/* Page header — h-16 matches sidebar logo area */}
|
||||||
<div className="bg-muted/40">
|
<div className="bg-muted/40 border-b border-border/40">
|
||||||
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-space-md)] sm:px-[var(--cp-space-lg)] md:px-8 py-[var(--cp-space-lg)] sm:py-[var(--cp-space-xl)]">
|
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-space-md)] sm:px-[var(--cp-space-lg)] md:px-8 h-16 flex items-center">
|
||||||
{backLink && (
|
<div className="flex items-center justify-between gap-4 min-w-0 w-full">
|
||||||
<div className="mb-[var(--cp-space-sm)] sm:mb-[var(--cp-space-md)]">
|
<div className="flex items-center min-w-0 flex-1">
|
||||||
<Link
|
{backLink && (
|
||||||
href={backLink.href}
|
<Link
|
||||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors duration-200"
|
href={backLink.href}
|
||||||
>
|
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors duration-200 mr-3 flex-shrink-0"
|
||||||
<ArrowLeftIcon className="h-4 w-4" />
|
>
|
||||||
{backLink.label}
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
</Link>
|
{backLink.label}
|
||||||
</div>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-[var(--cp-space-md)] sm:gap-[var(--cp-space-lg)]">
|
{icon && <div className="h-6 w-6 text-primary mr-2.5 flex-shrink-0">{icon}</div>}
|
||||||
<div className="flex items-start justify-between gap-4 min-w-0">
|
<h1 className="text-lg font-bold text-foreground leading-tight truncate">{title}</h1>
|
||||||
<div className="flex items-start min-w-0 flex-1">
|
{statusPill && <div className="ml-2.5 flex-shrink-0">{statusPill}</div>}
|
||||||
{icon && (
|
{description && (
|
||||||
<div className="h-7 w-7 sm:h-8 sm:w-8 text-primary mr-[var(--cp-space-md)] sm:mr-[var(--cp-space-lg)] flex-shrink-0 mt-0.5">
|
<p className="hidden sm:block text-sm text-muted-foreground ml-3 truncate">
|
||||||
{icon}
|
{description}
|
||||||
</div>
|
</p>
|
||||||
)}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold text-foreground leading-tight">
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
{statusPill}
|
|
||||||
</div>
|
|
||||||
{description && (
|
|
||||||
<p className="text-sm text-muted-foreground mt-1 leading-relaxed line-clamp-2 sm:line-clamp-none">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{actions && (
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 flex-shrink-0">
|
|
||||||
{actions}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{actions && (
|
||||||
|
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">{actions}</div>
|
||||||
|
)}
|
||||||
|
{/* NotificationBell is rendered by AppShell via #page-header-end portal */}
|
||||||
|
<div id="page-header-end" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -38,12 +38,12 @@ const getBrandColor = (brand?: string) => {
|
|||||||
|
|
||||||
const getMethodIcon = (type: PaymentMethod["type"], brand?: string) => {
|
const getMethodIcon = (type: PaymentMethod["type"], brand?: string) => {
|
||||||
const baseClasses =
|
const baseClasses =
|
||||||
"w-12 h-12 bg-gradient-to-br rounded-xl flex items-center justify-center shadow-sm";
|
"w-9 h-9 bg-gradient-to-br rounded-lg flex items-center justify-center shadow-sm";
|
||||||
|
|
||||||
if (isBankAccount(type)) {
|
if (isBankAccount(type)) {
|
||||||
return (
|
return (
|
||||||
<div className={`${baseClasses} from-green-500 to-green-600`}>
|
<div className={`${baseClasses} from-green-500 to-green-600`}>
|
||||||
<BanknotesIcon className="h-6 w-6 text-white" />
|
<BanknotesIcon className="h-[18px] w-[18px] text-white" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -55,7 +55,7 @@ const getMethodIcon = (type: PaymentMethod["type"], brand?: string) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${baseClasses} ${brandColor}`}>
|
<div className={`${baseClasses} ${brandColor}`}>
|
||||||
<IconComponent className="h-6 w-6 text-white" />
|
<IconComponent className="h-[18px] w-[18px] text-white" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -82,16 +82,16 @@ export function PaymentMethodCard({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-between p-6 border border-gray-200 rounded-xl bg-white transition-all duration-200 hover:shadow-sm",
|
"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",
|
paymentMethod.isDefault && "ring-2 ring-blue-500/20 border-blue-200 bg-blue-50/30",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-5 flex-1 min-w-0">
|
<div className="flex items-center gap-3.5 flex-1 min-w-0">
|
||||||
<div className="flex-shrink-0">{icon}</div>
|
<div className="flex-shrink-0">{icon}</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-3 mb-1">
|
<div className="flex items-center gap-2.5 mb-0.5">
|
||||||
<h3 className="font-semibold text-gray-900 text-lg font-mono">{cardDisplay}</h3>
|
<h3 className="font-semibold text-gray-900 text-sm font-mono">{cardDisplay}</h3>
|
||||||
{paymentMethod.isDefault && (
|
{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-blue-100 text-blue-800 text-xs font-medium px-2 py-1 rounded-full">
|
||||||
<CheckCircleIcon className="h-3 w-3" />
|
<CheckCircleIcon className="h-3 w-3" />
|
||||||
@ -100,7 +100,7 @@ export function PaymentMethodCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 text-sm">
|
<div className="flex items-center gap-3 text-xs">
|
||||||
{cardBrand && <span className="text-gray-600 font-medium">{cardBrand}</span>}
|
{cardBrand && <span className="text-gray-600 font-medium">{cardBrand}</span>}
|
||||||
{expiry && (
|
{expiry && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -77,8 +77,8 @@ function PaymentMethodsSection({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{hasMethods && (
|
{hasMethods && (
|
||||||
<div className="p-6">
|
<div className="p-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
{paymentMethodsData.paymentMethods.map(paymentMethod => (
|
{paymentMethodsData.paymentMethods.map(paymentMethod => (
|
||||||
<PaymentMethodCard key={paymentMethod.id} paymentMethod={paymentMethod} />
|
<PaymentMethodCard key={paymentMethod.id} paymentMethod={paymentMethod} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -65,21 +65,16 @@ function DashboardGreeting({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<motion.h2
|
<motion.div
|
||||||
className="text-2xl sm:text-3xl font-bold text-foreground font-heading tracking-tight"
|
className="flex flex-wrap items-center gap-x-3 gap-y-2"
|
||||||
initial={{ opacity: 0, y: 8 }}
|
initial={{ opacity: 0, y: 8 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
>
|
>
|
||||||
Welcome back, {displayName}
|
<h2 className="text-2xl sm:text-3xl font-bold text-foreground font-heading tracking-tight">
|
||||||
</motion.h2>
|
Welcome back, {displayName}
|
||||||
{taskCount > 0 ? (
|
</h2>
|
||||||
<motion.div
|
{taskCount > 0 ? (
|
||||||
className="flex items-center gap-2 mt-2"
|
|
||||||
initial={{ opacity: 0, y: 16 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.5, delay: 0.05 }}
|
|
||||||
>
|
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium",
|
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium",
|
||||||
@ -89,17 +84,10 @@ function DashboardGreeting({
|
|||||||
{hasUrgentTask && <ExclamationTriangleIcon className="h-3.5 w-3.5" />}
|
{hasUrgentTask && <ExclamationTriangleIcon className="h-3.5 w-3.5" />}
|
||||||
{taskCount === 1 ? "1 task needs attention" : `${taskCount} tasks need attention`}
|
{taskCount === 1 ? "1 task needs attention" : `${taskCount} tasks need attention`}
|
||||||
</span>
|
</span>
|
||||||
</motion.div>
|
) : (
|
||||||
) : (
|
<span className="text-sm text-muted-foreground">Everything is up to date</span>
|
||||||
<motion.p
|
)}
|
||||||
className="text-sm text-muted-foreground mt-1.5"
|
</motion.div>
|
||||||
initial={{ opacity: 0, y: 16 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.5, delay: 0.05 }}
|
|
||||||
>
|
|
||||||
Everything is up to date
|
|
||||||
</motion.p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -311,6 +311,11 @@ function SimTabSwitcher({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cardVariants = {
|
||||||
|
hidden: { opacity: 0, y: 16 },
|
||||||
|
visible: { opacity: 1, y: 0, transition: { duration: 0.3, ease: "easeOut" as const } },
|
||||||
|
};
|
||||||
|
|
||||||
function SimPlansGrid({
|
function SimPlansGrid({
|
||||||
regularPlans,
|
regularPlans,
|
||||||
familyPlans,
|
familyPlans,
|
||||||
@ -344,15 +349,28 @@ function SimPlansGrid({
|
|||||||
<motion.div
|
<motion.div
|
||||||
key={activeTab}
|
key={activeTab}
|
||||||
className="space-y-8"
|
className="space-y-8"
|
||||||
initial={{ opacity: 0, x: slideDirection === "left" ? 24 : -24 }}
|
initial="hidden"
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate="visible"
|
||||||
exit={{ opacity: 0, x: slideDirection === "left" ? -24 : 24 }}
|
exit="exit"
|
||||||
transition={{ duration: 0.3, ease: "easeOut" as const }}
|
variants={{
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: { staggerChildren: 0.06 },
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
opacity: 0,
|
||||||
|
x: slideDirection === "left" ? -24 : 24,
|
||||||
|
transition: { duration: 0.2 },
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{regularPlans.length > 0 && (
|
{regularPlans.length > 0 && (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
{regularPlans.map(plan => (
|
{regularPlans.map(plan => (
|
||||||
<SimPlanCardCompact key={plan.id} plan={plan} onSelect={onSelectPlan} />
|
<motion.div key={plan.id} variants={cardVariants}>
|
||||||
|
<SimPlanCardCompact plan={plan} onSelect={onSelectPlan} />
|
||||||
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -365,7 +383,9 @@ function SimPlansGrid({
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
{familyPlans.map(plan => (
|
{familyPlans.map(plan => (
|
||||||
<SimPlanCardCompact key={plan.id} plan={plan} isFamily onSelect={onSelectPlan} />
|
<motion.div key={plan.id} variants={cardVariants}>
|
||||||
|
<SimPlanCardCompact plan={plan} isFamily onSelect={onSelectPlan} />
|
||||||
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -115,6 +115,24 @@ function getOfferingTypeId(offeringType: string | undefined): string {
|
|||||||
return "home1g";
|
return "home1g";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cardVariants = {
|
||||||
|
hidden: { opacity: 0, y: 16 },
|
||||||
|
visible: { opacity: 1, y: 0, transition: { duration: 0.3, ease: "easeOut" as const } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const tierContainerVariants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: { staggerChildren: 0.08 },
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
opacity: 0,
|
||||||
|
x: -24,
|
||||||
|
transition: { duration: 0.2 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Unified Internet Card ────────────────────────────────────────────────────
|
// ─── Unified Internet Card ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const OFFERING_DESCRIPTIONS: Record<string, string> = {
|
const OFFERING_DESCRIPTIONS: Record<string, string> = {
|
||||||
@ -307,13 +325,15 @@ function UnifiedInternetCard({
|
|||||||
<motion.div
|
<motion.div
|
||||||
key={selectedOffering}
|
key={selectedOffering}
|
||||||
className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6"
|
className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6"
|
||||||
initial={{ opacity: 0, x: 24 }}
|
initial="hidden"
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate="visible"
|
||||||
exit={{ opacity: 0, x: -24 }}
|
exit="exit"
|
||||||
transition={{ duration: 0.3, ease: "easeOut" as const }}
|
variants={tierContainerVariants}
|
||||||
>
|
>
|
||||||
{displayTiers.map(tier => (
|
{displayTiers.map(tier => (
|
||||||
<TierCard key={tier.tier} tier={tier} />
|
<motion.div key={tier.tier} variants={cardVariants}>
|
||||||
|
<TierCard tier={tier} />
|
||||||
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
@ -653,13 +673,23 @@ function FaqItem({ question, answer }: { question: string; answer: string }) {
|
|||||||
className="w-full flex items-start justify-between gap-3 p-4 text-left hover:bg-muted/50 transition-colors"
|
className="w-full flex items-start justify-between gap-3 p-4 text-left hover:bg-muted/50 transition-colors"
|
||||||
>
|
>
|
||||||
<span className="font-medium text-foreground">{question}</span>
|
<span className="font-medium text-foreground">{question}</span>
|
||||||
<ChevronDown
|
<motion.div animate={{ rotate: isOpen ? 180 : 0 }} transition={{ duration: 0.2 }}>
|
||||||
className={`w-5 h-5 text-muted-foreground flex-shrink-0 mt-0.5 transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
|
<ChevronDown className="w-5 h-5 text-muted-foreground flex-shrink-0 mt-0.5" />
|
||||||
/>
|
</motion.div>
|
||||||
</button>
|
</button>
|
||||||
{isOpen && (
|
<AnimatePresence initial={false}>
|
||||||
<div className="px-4 pb-4 text-sm text-muted-foreground leading-relaxed">{answer}</div>
|
{isOpen && (
|
||||||
)}
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: "auto", opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||||
|
style={{ overflow: "hidden" }}
|
||||||
|
>
|
||||||
|
<div className="px-4 pb-4 text-sm text-muted-foreground leading-relaxed">{answer}</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
import { apiClient } from "@/core/api";
|
import { apiClient } from "@/core/api";
|
||||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||||
import { mapToSimplifiedFormat } from "../../utils/plan";
|
import { mapToSimplifiedFormat } from "../../utils/plan";
|
||||||
@ -84,14 +85,21 @@ export function ChangePlanModal({
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
<div
|
<motion.div
|
||||||
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
className="fixed inset-0 bg-gray-500 bg-opacity-75"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></div>
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
/>
|
||||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||||
​
|
​
|
||||||
</span>
|
</span>
|
||||||
<div className="relative z-10 inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
<motion.div
|
||||||
|
className="relative z-10 inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||||
|
>
|
||||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||||
<div className="sm:flex sm:items-start">
|
<div className="sm:flex sm:items-start">
|
||||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
|
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
|
||||||
@ -129,7 +137,7 @@ export function ChangePlanModal({
|
|||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { apiClient } from "@/core/api";
|
import { apiClient } from "@/core/api";
|
||||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||||
import { Button } from "@/components/atoms/button";
|
import { Button } from "@/components/atoms/button";
|
||||||
@ -11,9 +12,7 @@ const TOGGLE_BASE =
|
|||||||
const TOGGLE_ACTIVE = "bg-primary";
|
const TOGGLE_ACTIVE = "bg-primary";
|
||||||
const TOGGLE_INACTIVE = "bg-muted";
|
const TOGGLE_INACTIVE = "bg-muted";
|
||||||
const TOGGLE_KNOB_BASE =
|
const TOGGLE_KNOB_BASE =
|
||||||
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-background shadow ring-0 transition duration-[var(--cp-duration-normal)] ease-in-out";
|
"pointer-events-none inline-block h-5 w-5 rounded-full bg-background shadow ring-0";
|
||||||
const TOGGLE_KNOB_ON = "translate-x-5";
|
|
||||||
const TOGGLE_KNOB_OFF = "translate-x-0";
|
|
||||||
|
|
||||||
interface SimFeatureTogglesProps {
|
interface SimFeatureTogglesProps {
|
||||||
subscriptionId: number;
|
subscriptionId: number;
|
||||||
@ -211,7 +210,6 @@ function FeatureToggleRow({
|
|||||||
onChange,
|
onChange,
|
||||||
}: FeatureToggleRowProps): React.ReactElement {
|
}: FeatureToggleRowProps): React.ReactElement {
|
||||||
const toggleBgClass = checked ? TOGGLE_ACTIVE : TOGGLE_INACTIVE;
|
const toggleBgClass = checked ? TOGGLE_ACTIVE : TOGGLE_INACTIVE;
|
||||||
const knobPositionClass = checked ? TOGGLE_KNOB_ON : TOGGLE_KNOB_OFF;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between py-4">
|
<div className="flex items-center justify-between py-4">
|
||||||
@ -226,7 +224,11 @@ function FeatureToggleRow({
|
|||||||
onClick={onChange}
|
onClick={onChange}
|
||||||
className={`${TOGGLE_BASE} ${toggleBgClass}`}
|
className={`${TOGGLE_BASE} ${toggleBgClass}`}
|
||||||
>
|
>
|
||||||
<span className={`${TOGGLE_KNOB_BASE} ${knobPositionClass}`} />
|
<motion.span
|
||||||
|
className={TOGGLE_KNOB_BASE}
|
||||||
|
animate={{ x: checked ? 20 : 0 }}
|
||||||
|
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -238,6 +240,8 @@ interface NetworkTypeSelectorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function NetworkTypeSelector({ nt, setNt }: NetworkTypeSelectorProps): React.ReactElement {
|
function NetworkTypeSelector({ nt, setNt }: NetworkTypeSelectorProps): React.ReactElement {
|
||||||
|
const options: Array<"4G" | "5G"> = ["4G", "5G"];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-border pt-6">
|
<div className="border-t border-border pt-6">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
@ -248,48 +252,40 @@ function NetworkTypeSelector({ nt, setNt }: NetworkTypeSelectorProps): React.Rea
|
|||||||
changed another option, you may need to wait before submitting.
|
changed another option, you may need to wait before submitting.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4">
|
<div
|
||||||
<NetworkRadioOption id="4g" value="4G" label="4G" checked={nt === "4G"} onChange={setNt} />
|
className="inline-flex rounded-lg bg-muted/60 p-1"
|
||||||
<NetworkRadioOption id="5g" value="5G" label="5G" checked={nt === "5G"} onChange={setNt} />
|
role="radiogroup"
|
||||||
|
aria-label="Network Type"
|
||||||
|
>
|
||||||
|
{options.map(value => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={nt === value}
|
||||||
|
onClick={() => setNt(value)}
|
||||||
|
className="relative rounded-md px-6 py-1.5 text-sm font-medium transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background"
|
||||||
|
>
|
||||||
|
{nt === value && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="network-type-indicator"
|
||||||
|
className="absolute inset-0 rounded-md bg-background shadow-sm"
|
||||||
|
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`relative z-10 ${nt === value ? "text-foreground" : "text-muted-foreground"}`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-2">5G connectivity for enhanced speeds</p>
|
<p className="text-xs text-muted-foreground mt-2">5G connectivity for enhanced speeds</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NetworkRadioOptionProps {
|
|
||||||
id: string;
|
|
||||||
value: "4G" | "5G";
|
|
||||||
label: string;
|
|
||||||
checked: boolean;
|
|
||||||
onChange: (value: "4G" | "5G") => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function NetworkRadioOption({
|
|
||||||
id,
|
|
||||||
value,
|
|
||||||
label,
|
|
||||||
checked,
|
|
||||||
onChange,
|
|
||||||
}: NetworkRadioOptionProps): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
id={id}
|
|
||||||
name="networkType"
|
|
||||||
value={value}
|
|
||||||
checked={checked}
|
|
||||||
onChange={() => onChange(value)}
|
|
||||||
className="h-4 w-4 text-primary focus:ring-ring border-input"
|
|
||||||
/>
|
|
||||||
<label htmlFor={id} className="text-sm text-foreground/80">
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NotesAndActionsSectionProps {
|
interface NotesAndActionsSectionProps {
|
||||||
embedded: boolean;
|
embedded: boolean;
|
||||||
success: string | null;
|
success: string | null;
|
||||||
@ -326,21 +322,36 @@ function NotesAndActionsSection({
|
|||||||
</ul>
|
</ul>
|
||||||
</AlertBanner>
|
</AlertBanner>
|
||||||
|
|
||||||
{success && (
|
<AnimatePresence>
|
||||||
<div className="mb-4">
|
{success && (
|
||||||
<AlertBanner variant="success" title="Success" size="sm" elevated>
|
<motion.div
|
||||||
{success}
|
key="success-banner"
|
||||||
</AlertBanner>
|
initial={{ opacity: 0, height: 0, y: -8 }}
|
||||||
</div>
|
animate={{ opacity: 1, height: "auto", y: 0 }}
|
||||||
)}
|
exit={{ opacity: 0, height: 0, y: -8 }}
|
||||||
|
transition={{ duration: 0.25, ease: "easeInOut" }}
|
||||||
{error && (
|
className="mb-4 overflow-hidden"
|
||||||
<div className="mb-4">
|
>
|
||||||
<AlertBanner variant="error" title="Unable to apply changes" size="sm" elevated>
|
<AlertBanner variant="success" title="Success" size="sm" elevated>
|
||||||
{error}
|
{success}
|
||||||
</AlertBanner>
|
</AlertBanner>
|
||||||
</div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
{error && (
|
||||||
|
<motion.div
|
||||||
|
key="error-banner"
|
||||||
|
initial={{ opacity: 0, height: 0, y: -8 }}
|
||||||
|
animate={{ opacity: 1, height: "auto", y: 0 }}
|
||||||
|
exit={{ opacity: 0, height: 0, y: -8 }}
|
||||||
|
transition={{ duration: 0.25, ease: "easeInOut" }}
|
||||||
|
className="mb-4 overflow-hidden"
|
||||||
|
>
|
||||||
|
<AlertBanner variant="error" title="Unable to apply changes" size="sm" elevated>
|
||||||
|
{error}
|
||||||
|
</AlertBanner>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3">
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
<Button className="flex-1" onClick={onApply} loading={loading} loadingText="Applying…">
|
<Button className="flex-1" onClick={onApply} loading={loading} loadingText="Applying…">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user