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:
barsa 2026-03-06 15:42:25 +09:00
parent 7502068ea9
commit 4ee9cb526b
10 changed files with 223 additions and 158 deletions

View File

@ -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 />}
</> </>
); );
} }

View File

@ -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>

View File

@ -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 && (
<> <>

View File

@ -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} />
))} ))}

View File

@ -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>
); );
} }

View File

@ -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>

View File

@ -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>
); );
} }

View File

@ -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">
&#8203; &#8203;
</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>
); );

View File

@ -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…">

BIN
image.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 440 KiB

After

Width:  |  Height:  |  Size: 30 KiB