docs: add invoice optimization design plan
Remove N+1 subscription invoice scanning, clean up billing service layer, and simplify InvoicesList component to single-purpose.
This commit is contained in:
parent
9145b4aaed
commit
4ebfc4c254
@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url";
|
|||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const workspaceRoot = path.resolve(__dirname, "..", "..");
|
const workspaceRoot = path.resolve(__dirname, "..", "..");
|
||||||
|
const domainDist = path.join(workspaceRoot, "packages/domain/dist");
|
||||||
|
|
||||||
// BFF URL for development API proxying
|
// BFF URL for development API proxying
|
||||||
const BFF_URL = process.env.BFF_URL || "http://localhost:4000";
|
const BFF_URL = process.env.BFF_URL || "http://localhost:4000";
|
||||||
@ -19,32 +20,29 @@ const nextConfig = {
|
|||||||
|
|
||||||
turbopack: {
|
turbopack: {
|
||||||
resolveAlias: {
|
resolveAlias: {
|
||||||
"@customer-portal/domain": path.join(workspaceRoot, "packages/domain/dist"),
|
"@customer-portal/domain": domainDist,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
webpack(config, { dev }) {
|
webpack(config, { dev }) {
|
||||||
config.resolve.alias = {
|
config.resolve.alias = {
|
||||||
...config.resolve.alias,
|
...config.resolve.alias,
|
||||||
"@customer-portal/domain": path.join(workspaceRoot, "packages/domain/dist"),
|
"@customer-portal/domain": domainDist,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Watch domain package dist for changes in development
|
|
||||||
if (dev) {
|
if (dev) {
|
||||||
config.watchOptions = {
|
config.watchOptions = {
|
||||||
...config.watchOptions,
|
...config.watchOptions,
|
||||||
ignored: config.watchOptions?.ignored
|
// Poll-based watching is more reliable on WSL2 where inotify can miss events
|
||||||
? [
|
poll: 1000,
|
||||||
...(Array.isArray(config.watchOptions.ignored)
|
aggregateTimeout: 300,
|
||||||
? config.watchOptions.ignored
|
ignored: ["**/node_modules/!(@@customer-portal)/**"],
|
||||||
: [config.watchOptions.ignored]),
|
|
||||||
].filter(p => !String(p).includes("packages/domain"))
|
|
||||||
: ["**/node_modules/**"],
|
|
||||||
};
|
};
|
||||||
// Add domain dist to snapshot managed paths for better change detection
|
// Only exclude the domain dist from managed paths so webpack detects domain rebuilds
|
||||||
|
// without nuking the entire resolution cache (which causes crash loops)
|
||||||
config.snapshot = {
|
config.snapshot = {
|
||||||
...config.snapshot,
|
...config.snapshot,
|
||||||
managedPaths: [],
|
managedPaths: [/[\\/]node_modules[\\/](?!@customer-portal[\\/])/],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { memo, useEffect, useState } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ArrowRight, ChevronLeft, ChevronRight } from "lucide-react";
|
import { ArrowRight, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { cn } from "@/shared/utils";
|
import { cn } from "@/shared/utils";
|
||||||
import { useInfiniteCarousel, useInView } from "@/features/landing-page/hooks";
|
import { useCarousel, useInView } from "@/features/landing-page/hooks";
|
||||||
import {
|
import {
|
||||||
personalConversionCards,
|
personalConversionCards,
|
||||||
businessConversionCards,
|
businessConversionCards,
|
||||||
@ -21,8 +21,8 @@ interface AccentStyles {
|
|||||||
iconText: string;
|
iconText: string;
|
||||||
ctaBg: string;
|
ctaBg: string;
|
||||||
dotBg: string;
|
dotBg: string;
|
||||||
borderInactive: string;
|
border: string;
|
||||||
borderActive: string;
|
glowFrom: string;
|
||||||
cssVar: string;
|
cssVar: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -30,130 +30,106 @@ const ACCENTS: Record<CarouselAccent, AccentStyles> = {
|
|||||||
blue: {
|
blue: {
|
||||||
iconBg: "bg-blue-500/12",
|
iconBg: "bg-blue-500/12",
|
||||||
iconText: "text-blue-600",
|
iconText: "text-blue-600",
|
||||||
ctaBg: "bg-blue-600",
|
ctaBg: "bg-blue-600 hover:bg-blue-700",
|
||||||
dotBg: "bg-blue-600",
|
dotBg: "bg-blue-600",
|
||||||
borderInactive: "border-blue-500/10",
|
border: "border-blue-500/20",
|
||||||
borderActive: "border-blue-500/25",
|
glowFrom: "from-blue-500/5",
|
||||||
cssVar: "var(--color-blue-500)",
|
cssVar: "var(--color-blue-500)",
|
||||||
},
|
},
|
||||||
emerald: {
|
emerald: {
|
||||||
iconBg: "bg-emerald-500/12",
|
iconBg: "bg-emerald-500/12",
|
||||||
iconText: "text-emerald-600",
|
iconText: "text-emerald-600",
|
||||||
ctaBg: "bg-emerald-600",
|
ctaBg: "bg-emerald-600 hover:bg-emerald-700",
|
||||||
dotBg: "bg-emerald-600",
|
dotBg: "bg-emerald-600",
|
||||||
borderInactive: "border-emerald-500/10",
|
border: "border-emerald-500/20",
|
||||||
borderActive: "border-emerald-500/25",
|
glowFrom: "from-emerald-500/5",
|
||||||
cssVar: "var(--color-emerald-500)",
|
cssVar: "var(--color-emerald-500)",
|
||||||
},
|
},
|
||||||
violet: {
|
violet: {
|
||||||
iconBg: "bg-violet-500/12",
|
iconBg: "bg-violet-500/12",
|
||||||
iconText: "text-violet-600",
|
iconText: "text-violet-600",
|
||||||
ctaBg: "bg-violet-600",
|
ctaBg: "bg-violet-600 hover:bg-violet-700",
|
||||||
dotBg: "bg-violet-600",
|
dotBg: "bg-violet-600",
|
||||||
borderInactive: "border-violet-500/10",
|
border: "border-violet-500/20",
|
||||||
borderActive: "border-violet-500/25",
|
glowFrom: "from-violet-500/5",
|
||||||
cssVar: "var(--color-violet-500)",
|
cssVar: "var(--color-violet-500)",
|
||||||
},
|
},
|
||||||
amber: {
|
amber: {
|
||||||
iconBg: "bg-amber-500/12",
|
iconBg: "bg-amber-500/12",
|
||||||
iconText: "text-amber-600",
|
iconText: "text-amber-600",
|
||||||
ctaBg: "bg-amber-600",
|
ctaBg: "bg-amber-600 hover:bg-amber-700",
|
||||||
dotBg: "bg-amber-600",
|
dotBg: "bg-amber-600",
|
||||||
borderInactive: "border-amber-500/10",
|
border: "border-amber-500/20",
|
||||||
borderActive: "border-amber-500/25",
|
glowFrom: "from-amber-500/5",
|
||||||
cssVar: "var(--color-amber-500)",
|
cssVar: "var(--color-amber-500)",
|
||||||
},
|
},
|
||||||
indigo: {
|
indigo: {
|
||||||
iconBg: "bg-indigo-500/12",
|
iconBg: "bg-indigo-500/12",
|
||||||
iconText: "text-indigo-600",
|
iconText: "text-indigo-600",
|
||||||
ctaBg: "bg-indigo-600",
|
ctaBg: "bg-indigo-600 hover:bg-indigo-700",
|
||||||
dotBg: "bg-indigo-600",
|
dotBg: "bg-indigo-600",
|
||||||
borderInactive: "border-indigo-500/10",
|
border: "border-indigo-500/20",
|
||||||
borderActive: "border-indigo-500/25",
|
glowFrom: "from-indigo-500/5",
|
||||||
cssVar: "var(--color-indigo-500)",
|
cssVar: "var(--color-indigo-500)",
|
||||||
},
|
},
|
||||||
cyan: {
|
cyan: {
|
||||||
iconBg: "bg-cyan-500/12",
|
iconBg: "bg-cyan-500/12",
|
||||||
iconText: "text-cyan-600",
|
iconText: "text-cyan-600",
|
||||||
ctaBg: "bg-cyan-600",
|
ctaBg: "bg-cyan-600 hover:bg-cyan-700",
|
||||||
dotBg: "bg-cyan-600",
|
dotBg: "bg-cyan-600",
|
||||||
borderInactive: "border-cyan-500/10",
|
border: "border-cyan-500/20",
|
||||||
borderActive: "border-cyan-500/25",
|
glowFrom: "from-cyan-500/5",
|
||||||
cssVar: "var(--color-cyan-500)",
|
cssVar: "var(--color-cyan-500)",
|
||||||
},
|
},
|
||||||
rose: {
|
rose: {
|
||||||
iconBg: "bg-rose-500/12",
|
iconBg: "bg-rose-500/12",
|
||||||
iconText: "text-rose-600",
|
iconText: "text-rose-600",
|
||||||
ctaBg: "bg-rose-600",
|
ctaBg: "bg-rose-600 hover:bg-rose-700",
|
||||||
dotBg: "bg-rose-600",
|
dotBg: "bg-rose-600",
|
||||||
borderInactive: "border-rose-500/10",
|
border: "border-rose-500/20",
|
||||||
borderActive: "border-rose-500/25",
|
glowFrom: "from-rose-500/5",
|
||||||
cssVar: "var(--color-rose-500)",
|
cssVar: "var(--color-rose-500)",
|
||||||
},
|
},
|
||||||
slate: {
|
slate: {
|
||||||
iconBg: "bg-slate-500/12",
|
iconBg: "bg-slate-500/12",
|
||||||
iconText: "text-slate-600",
|
iconText: "text-slate-600",
|
||||||
ctaBg: "bg-slate-600",
|
ctaBg: "bg-slate-600 hover:bg-slate-700",
|
||||||
dotBg: "bg-slate-600",
|
dotBg: "bg-slate-600",
|
||||||
borderInactive: "border-slate-500/10",
|
border: "border-slate-500/20",
|
||||||
borderActive: "border-slate-500/25",
|
glowFrom: "from-slate-500/5",
|
||||||
cssVar: "var(--color-slate-500)",
|
cssVar: "var(--color-slate-500)",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ─── Slide visual styles by distance from center ─── */
|
/* ─── Crossfade Card ─── */
|
||||||
|
|
||||||
const SLIDE_STYLES = [
|
const CrossfadeCard = memo(function CrossfadeCard({ card }: { card: ConversionServiceCard }) {
|
||||||
{ scale: 1, opacity: 1, filter: "none" },
|
|
||||||
{ scale: 0.88, opacity: 0.5, filter: "blur(2px)" },
|
|
||||||
{ scale: 0.78, opacity: 0.25, filter: "blur(4px)" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
function slideStyle(offset: number) {
|
|
||||||
return SLIDE_STYLES[Math.min(offset, 2)] ?? SLIDE_STYLES[2]!;
|
|
||||||
}
|
|
||||||
|
|
||||||
const GAP = 24;
|
|
||||||
|
|
||||||
/* ─── Spotlight Card ─── */
|
|
||||||
|
|
||||||
const SpotlightCard = memo(function SpotlightCard({
|
|
||||||
card,
|
|
||||||
isActive,
|
|
||||||
}: {
|
|
||||||
card: ConversionServiceCard;
|
|
||||||
isActive: boolean;
|
|
||||||
}) {
|
|
||||||
const a = ACCENTS[card.accent];
|
const a = ACCENTS[card.accent];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={card.href}
|
href={card.href}
|
||||||
tabIndex={isActive ? 0 : -1}
|
|
||||||
aria-hidden={!isActive}
|
|
||||||
onClick={e => {
|
|
||||||
if (!isActive) e.preventDefault();
|
|
||||||
}}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"block h-full rounded-3xl border overflow-hidden transition-shadow duration-500",
|
"block rounded-3xl border overflow-hidden",
|
||||||
isActive
|
"shadow-lg hover:shadow-xl transition-shadow duration-300",
|
||||||
? cn("shadow-xl hover:shadow-2xl", a.borderActive)
|
a.border
|
||||||
: cn("shadow-sm", a.borderInactive)
|
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(145deg, color-mix(in oklch, ${a.cssVar} 8%, white), white)`,
|
background: `linear-gradient(145deg, color-mix(in oklch, ${a.cssVar} 6%, white), white)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="h-full flex flex-col px-7 py-7 sm:px-10 sm:py-9">
|
<div className="flex flex-col sm:flex-row gap-6 sm:gap-10 p-7 sm:p-10">
|
||||||
<div className="flex items-center justify-between mb-5">
|
{/* Left: Content */}
|
||||||
|
<div className="flex-1 flex flex-col justify-center min-w-0">
|
||||||
|
<div className="flex items-center gap-3 mb-5">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-14 w-14 rounded-2xl flex items-center justify-center",
|
"h-12 w-12 rounded-2xl flex items-center justify-center shrink-0",
|
||||||
a.iconBg,
|
a.iconBg,
|
||||||
a.iconText
|
a.iconText
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="[&>svg]:h-7 [&>svg]:w-7">{card.icon}</div>
|
<div className="[&>svg]:h-6 [&>svg]:w-6">{card.icon}</div>
|
||||||
</div>
|
</div>
|
||||||
{card.badge && (
|
{card.badge && (
|
||||||
<span className="inline-flex items-center rounded-full bg-success/10 text-success px-3 py-1 text-xs font-bold tracking-wide">
|
<span className="inline-flex items-center rounded-full bg-success/10 text-success px-3 py-1 text-xs font-bold tracking-wide">
|
||||||
@ -161,16 +137,19 @@ const SpotlightCard = memo(function SpotlightCard({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium text-muted-foreground mb-1.5">{card.problemHook}</p>
|
|
||||||
|
<p className="text-sm font-medium text-muted-foreground mb-1">{card.problemHook}</p>
|
||||||
<h3 className="text-2xl sm:text-3xl font-extrabold text-foreground mb-3 leading-tight">
|
<h3 className="text-2xl sm:text-3xl font-extrabold text-foreground mb-3 leading-tight">
|
||||||
{card.title}
|
{card.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[15px] text-muted-foreground leading-relaxed mb-6 flex-grow">
|
<p className="text-[15px] text-muted-foreground leading-relaxed mb-6 max-w-lg">
|
||||||
{card.description}
|
{card.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-2 rounded-full px-6 py-3 text-sm font-bold text-white transition-shadow duration-200 self-start shadow-md hover:shadow-lg",
|
"inline-flex items-center gap-2 rounded-full px-6 py-3 text-sm font-bold text-white",
|
||||||
|
"transition-all duration-200 self-start shadow-md hover:shadow-lg hover:gap-3",
|
||||||
a.ctaBg
|
a.ctaBg
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -178,6 +157,29 @@ const SpotlightCard = memo(function SpotlightCard({
|
|||||||
<ArrowRight className="h-4 w-4" />
|
<ArrowRight className="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Key benefit highlight */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"hidden sm:flex items-center justify-center w-56 shrink-0",
|
||||||
|
"rounded-2xl bg-gradient-to-br to-transparent",
|
||||||
|
a.glowFrom
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="text-center px-6 py-8">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-16 w-16 rounded-2xl flex items-center justify-center mx-auto mb-4",
|
||||||
|
a.iconBg,
|
||||||
|
a.iconText
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="[&>svg]:h-8 [&>svg]:w-8">{card.icon}</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-semibold text-foreground/80">{card.keyBenefit}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -192,7 +194,7 @@ function CarouselHeader({
|
|||||||
onTabChange: (tab: Tab) => void;
|
onTabChange: (tab: Tab) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-6xl px-6 sm:px-10 lg:px-14 mb-10">
|
<div className="mx-auto max-w-3xl px-6 sm:px-10 mb-10">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-3xl sm:text-4xl font-extrabold text-foreground">Our Services</h2>
|
<h2 className="text-3xl sm:text-4xl font-extrabold text-foreground">Our Services</h2>
|
||||||
@ -238,7 +240,7 @@ function CarouselNav({
|
|||||||
goNext: () => void;
|
goNext: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-6xl px-6 sm:px-10 lg:px-14">
|
<div className="mx-auto max-w-3xl px-6 sm:px-10">
|
||||||
<div className="flex items-center justify-center gap-6 mt-8">
|
<div className="flex items-center justify-center gap-6 mt-8">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -286,14 +288,11 @@ export function ServicesCarousel() {
|
|||||||
const [activeTab, setActiveTab] = useState<Tab>("personal");
|
const [activeTab, setActiveTab] = useState<Tab>("personal");
|
||||||
const [sectionRef, isInView] = useInView();
|
const [sectionRef, isInView] = useInView();
|
||||||
const cards = activeTab === "personal" ? personalConversionCards : businessConversionCards;
|
const cards = activeTab === "personal" ? personalConversionCards : businessConversionCards;
|
||||||
const c = useInfiniteCarousel({ items: cards });
|
const c = useCarousel({ items: cards });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
c.reset();
|
c.reset();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [activeTab, c.reset]);
|
||||||
}, [activeTab]);
|
|
||||||
|
|
||||||
const trackX = -(c.trackIndex * (c.cardWidth + GAP));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
@ -306,9 +305,7 @@ export function ServicesCarousel() {
|
|||||||
<CarouselHeader activeTab={activeTab} onTabChange={setActiveTab} />
|
<CarouselHeader activeTab={activeTab} onTabChange={setActiveTab} />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="relative overflow-hidden"
|
className="mx-auto max-w-3xl px-6 sm:px-10"
|
||||||
onMouseEnter={c.stopAuto}
|
|
||||||
onMouseLeave={c.startAuto}
|
|
||||||
onTouchStart={c.onTouchStart}
|
onTouchStart={c.onTouchStart}
|
||||||
onTouchEnd={c.onTouchEnd}
|
onTouchEnd={c.onTouchEnd}
|
||||||
onKeyDown={c.onKeyDown}
|
onKeyDown={c.onKeyDown}
|
||||||
@ -317,41 +314,29 @@ export function ServicesCarousel() {
|
|||||||
aria-label="Services carousel"
|
aria-label="Services carousel"
|
||||||
aria-roledescription="carousel"
|
aria-roledescription="carousel"
|
||||||
>
|
>
|
||||||
<div
|
{/* Grid stacking: all cards occupy the same cell, tallest defines height */}
|
||||||
className="flex ease-out"
|
<div className="grid grid-cols-1 grid-rows-1">
|
||||||
style={{
|
{cards.map((card, i) => {
|
||||||
transform: `translateX(calc(50% - ${c.cardWidth / 2}px + ${trackX}px))`,
|
const isActive = i === c.activeIndex;
|
||||||
gap: `${GAP}px`,
|
|
||||||
transitionProperty: c.isTransitioning ? "transform" : "none",
|
|
||||||
transitionDuration: c.isTransitioning ? "500ms" : "0ms",
|
|
||||||
}}
|
|
||||||
onTransitionEnd={c.handleTransitionEnd}
|
|
||||||
>
|
|
||||||
{c.extendedItems.map((card, i) => {
|
|
||||||
const offset = Math.abs(i - c.trackIndex);
|
|
||||||
const style = slideStyle(offset);
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`slide-${i}`}
|
key={card.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-shrink-0 transition-[transform,opacity,filter] duration-500 ease-out",
|
"col-start-1 row-start-1",
|
||||||
offset > 0 && "cursor-pointer"
|
"transition-[opacity,transform] duration-500 ease-out motion-reduce:transition-none",
|
||||||
|
isActive
|
||||||
|
? "opacity-100 translate-y-0 scale-100 z-10"
|
||||||
|
: cn(
|
||||||
|
"opacity-0 scale-[0.97] z-0 pointer-events-none",
|
||||||
|
c.direction === "next" ? "translate-y-3" : "-translate-y-3"
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
role="group"
|
role="group"
|
||||||
aria-roledescription="slide"
|
aria-roledescription="slide"
|
||||||
aria-label={`${c.activeIndex + 1} of ${c.total}: ${card.title}`}
|
aria-label={`${i + 1} of ${c.total}: ${card.title}`}
|
||||||
style={{
|
aria-hidden={!isActive}
|
||||||
width: c.cardWidth,
|
|
||||||
transform: `scale(${style.scale})`,
|
|
||||||
opacity: style.opacity,
|
|
||||||
filter: style.filter,
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (i < c.trackIndex) c.goPrev();
|
|
||||||
else if (i > c.trackIndex) c.goNext();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<SpotlightCard card={card} isActive={offset === 0} />
|
<CrossfadeCard card={card} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
export { useInfiniteCarousel } from "./useInfiniteCarousel";
|
export { useCarousel, useInfiniteCarousel } from "./useInfiniteCarousel";
|
||||||
export { useInView } from "./useInView";
|
export { useInView } from "./useInView";
|
||||||
export { useStickyCta } from "./useStickyCta";
|
export { useStickyCta } from "./useStickyCta";
|
||||||
|
|||||||
@ -1,41 +1,34 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { useAfterPaint } from "@/shared/hooks";
|
|
||||||
|
|
||||||
const AUTO_INTERVAL = 5000;
|
export function useCarousel<T>({ items }: { items: T[] }) {
|
||||||
|
const total = items.length;
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
const [direction, setDirection] = useState<"next" | "prev">("next");
|
||||||
|
const activeIndexRef = useRef(activeIndex);
|
||||||
|
activeIndexRef.current = activeIndex;
|
||||||
|
const touchXRef = useRef(0);
|
||||||
|
|
||||||
function useResponsiveCardWidth() {
|
const goTo = useCallback((i: number) => {
|
||||||
const [cardWidth, setCardWidth] = useState(520);
|
setDirection(i > activeIndexRef.current ? "next" : "prev");
|
||||||
const rafRef = useRef(0);
|
setActiveIndex(i);
|
||||||
const prevWidthRef = useRef(520);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const update = () => {
|
|
||||||
const vw = window.innerWidth;
|
|
||||||
const next = vw < 640 ? vw - 48 : vw < 1024 ? 440 : 520;
|
|
||||||
if (next !== prevWidthRef.current) {
|
|
||||||
prevWidthRef.current = next;
|
|
||||||
setCardWidth(next);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const onResize = () => {
|
|
||||||
cancelAnimationFrame(rafRef.current);
|
|
||||||
rafRef.current = requestAnimationFrame(update);
|
|
||||||
};
|
|
||||||
update();
|
|
||||||
window.addEventListener("resize", onResize);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("resize", onResize);
|
|
||||||
cancelAnimationFrame(rafRef.current);
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return cardWidth;
|
const goNext = useCallback(() => {
|
||||||
}
|
setDirection("next");
|
||||||
|
setActiveIndex(prev => (prev + 1) % total);
|
||||||
|
}, [total]);
|
||||||
|
|
||||||
function useCarouselInput(goPrev: () => void, goNext: () => void) {
|
const goPrev = useCallback(() => {
|
||||||
const touchXRef = useRef(0);
|
setDirection("prev");
|
||||||
|
setActiveIndex(prev => (prev - 1 + total) % total);
|
||||||
|
}, [total]);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setActiveIndex(0);
|
||||||
|
setDirection("next");
|
||||||
|
}, []);
|
||||||
|
|
||||||
const onTouchStart = useCallback((e: React.TouchEvent) => {
|
const onTouchStart = useCallback((e: React.TouchEvent) => {
|
||||||
const touch = e.touches[0];
|
const touch = e.touches[0];
|
||||||
@ -63,111 +56,20 @@ function useCarouselInput(goPrev: () => void, goNext: () => void) {
|
|||||||
[goPrev, goNext]
|
[goPrev, goNext]
|
||||||
);
|
);
|
||||||
|
|
||||||
return { onTouchStart, onTouchEnd, onKeyDown };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useInfiniteCarousel<T>({ items }: { items: T[] }) {
|
|
||||||
const total = items.length;
|
|
||||||
const totalRef = useRef(total);
|
|
||||||
totalRef.current = total;
|
|
||||||
|
|
||||||
const autoRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
const cardWidth = useResponsiveCardWidth();
|
|
||||||
const [trackIndex, setTrackIndex] = useState(1);
|
|
||||||
const [isTransitioning, setIsTransitioning] = useState(true);
|
|
||||||
|
|
||||||
const extendedItems = useMemo(() => {
|
|
||||||
if (total === 0) return [];
|
|
||||||
return [items[total - 1]!, ...items, items[0]!];
|
|
||||||
}, [items, total]);
|
|
||||||
|
|
||||||
const activeIndex = (((trackIndex - 1) % total) + total) % total;
|
|
||||||
|
|
||||||
const startAuto = useCallback(() => {
|
|
||||||
if (autoRef.current) clearInterval(autoRef.current);
|
|
||||||
autoRef.current = setInterval(() => {
|
|
||||||
setTrackIndex(prev => {
|
|
||||||
const t = totalRef.current;
|
|
||||||
if (prev <= 0 || prev >= t + 1) return prev;
|
|
||||||
return prev + 1;
|
|
||||||
});
|
|
||||||
setIsTransitioning(true);
|
|
||||||
}, AUTO_INTERVAL);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const stopAuto = useCallback(() => {
|
|
||||||
if (autoRef.current) {
|
|
||||||
clearInterval(autoRef.current);
|
|
||||||
autoRef.current = null;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
startAuto();
|
|
||||||
return stopAuto;
|
|
||||||
}, [startAuto, stopAuto]);
|
|
||||||
|
|
||||||
const handleTransitionEnd = useCallback((e: React.TransitionEvent) => {
|
|
||||||
// Only respond to the track's own transform transition,
|
|
||||||
// not bubbled events from child slide transitions (scale/opacity/filter)
|
|
||||||
if (e.target !== e.currentTarget || e.propertyName !== "transform") return;
|
|
||||||
|
|
||||||
setTrackIndex(prev => {
|
|
||||||
const t = totalRef.current;
|
|
||||||
if (prev >= t + 1) {
|
|
||||||
setIsTransitioning(false);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
if (prev <= 0) {
|
|
||||||
setIsTransitioning(false);
|
|
||||||
return t;
|
|
||||||
}
|
|
||||||
return prev;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const enableTransition = useCallback(() => setIsTransitioning(true), []);
|
|
||||||
useAfterPaint(enableTransition, !isTransitioning);
|
|
||||||
|
|
||||||
const navigate = useCallback(
|
|
||||||
(updater: number | ((prev: number) => number)) => {
|
|
||||||
setTrackIndex(prev => {
|
|
||||||
const t = totalRef.current;
|
|
||||||
// Block navigation while at a clone position (snap-back pending)
|
|
||||||
if (prev <= 0 || prev >= t + 1) return prev;
|
|
||||||
return typeof updater === "function" ? updater(prev) : updater;
|
|
||||||
});
|
|
||||||
setIsTransitioning(true);
|
|
||||||
startAuto();
|
|
||||||
},
|
|
||||||
[startAuto]
|
|
||||||
);
|
|
||||||
|
|
||||||
const goTo = useCallback((i: number) => navigate(i + 1), [navigate]);
|
|
||||||
const goPrev = useCallback(() => navigate(p => p - 1), [navigate]);
|
|
||||||
const goNext = useCallback(() => navigate(p => p + 1), [navigate]);
|
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
|
||||||
setTrackIndex(1);
|
|
||||||
setIsTransitioning(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const inputHandlers = useCarouselInput(goPrev, goNext);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
extendedItems,
|
items,
|
||||||
total,
|
total,
|
||||||
activeIndex,
|
activeIndex,
|
||||||
trackIndex,
|
direction,
|
||||||
cardWidth,
|
|
||||||
isTransitioning,
|
|
||||||
handleTransitionEnd,
|
|
||||||
goTo,
|
goTo,
|
||||||
goPrev,
|
|
||||||
goNext,
|
goNext,
|
||||||
|
goPrev,
|
||||||
reset,
|
reset,
|
||||||
startAuto,
|
onTouchStart,
|
||||||
stopAuto,
|
onTouchEnd,
|
||||||
...inputHandlers,
|
onKeyDown,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use `useCarousel` instead */
|
||||||
|
export const useInfiniteCarousel = useCarousel;
|
||||||
|
|||||||
86
docs/plans/2026-03-05-invoice-optimization-design.md
Normal file
86
docs/plans/2026-03-05-invoice-optimization-design.md
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
# Invoice System Optimization Design
|
||||||
|
|
||||||
|
**Date:** 2026-03-05
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Subscription invoice fetching is slow due to an N+1 query pattern. When viewing a subscription's invoices, the BFF:
|
||||||
|
|
||||||
|
1. Fetches ALL client invoices page by page (full table scan)
|
||||||
|
2. For each invoice, makes an individual `GetInvoice` WHMCS API call to get line items
|
||||||
|
3. Filters in-memory by `item.serviceId === subscriptionId`
|
||||||
|
|
||||||
|
For a client with 19 invoices: 1 list call + 19 individual detail calls = 20 WHMCS API calls with batching delays.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Remove subscription-specific invoice fetching entirely.** Most billing portals (Stripe, AWS, DigitalOcean) don't offer per-subscription invoice lists. The WHMCS `GetInvoices` API doesn't support filtering by service/subscription ID, making this fundamentally expensive.
|
||||||
|
|
||||||
|
Instead:
|
||||||
|
|
||||||
|
- Subscription detail page shows billing summary (already available on subscription object) + link to main invoices page
|
||||||
|
- Main invoices page uses efficient single `GetInvoices` call (already working well)
|
||||||
|
- Individual invoice detail already shows which subscription each line item belongs to
|
||||||
|
|
||||||
|
Additionally, clean up the billing service layer by removing the pass-through `BillingOrchestrator`.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### Files to DELETE
|
||||||
|
|
||||||
|
1. `apps/bff/src/modules/billing/services/billing-orchestrator.service.ts` - zero-logic pass-through
|
||||||
|
|
||||||
|
### BFF Files to EDIT
|
||||||
|
|
||||||
|
2. **`subscriptions-orchestrator.service.ts`** - Remove:
|
||||||
|
- `getSubscriptionInvoices()`, `tryGetCachedInvoices()`, `fetchAllRelatedInvoices()`, `paginateInvoices()`, `cacheInvoiceResults()` (~150 lines)
|
||||||
|
- Related type imports (`InvoiceItem`, `InvoiceList`)
|
||||||
|
- `WhmcsInvoiceService` dependency (if no other method uses it)
|
||||||
|
|
||||||
|
3. **`subscriptions.controller.ts`** - Remove:
|
||||||
|
- `GET :id/invoices` endpoint
|
||||||
|
- `SubscriptionInvoiceQueryDto`, `InvoiceListDto` DTOs
|
||||||
|
- `invoiceListSchema`, `InvoiceList`, `Validation` imports
|
||||||
|
- `subscriptionInvoiceQuerySchema`
|
||||||
|
|
||||||
|
4. **`whmcs-invoice.service.ts`** - Remove:
|
||||||
|
- `getInvoicesWithItems()` method (~70 lines)
|
||||||
|
- `chunkArray`, `sleep` imports (if unused after removal)
|
||||||
|
|
||||||
|
5. **`whmcs-cache.service.ts`** - Remove:
|
||||||
|
- `subscriptionInvoices` + `subscriptionInvoicesAll` cache configs
|
||||||
|
- `getSubscriptionInvoices()`, `setSubscriptionInvoices()`, `getSubscriptionInvoicesAll()`, `setSubscriptionInvoicesAll()`
|
||||||
|
- `buildSubscriptionInvoicesKey()`, `buildSubscriptionInvoicesAllKey()`
|
||||||
|
- Subscription invoice patterns in invalidation methods
|
||||||
|
|
||||||
|
6. **`billing.controller.ts`** - Replace `BillingOrchestrator` with direct `WhmcsPaymentService` + `WhmcsSsoService` injection
|
||||||
|
|
||||||
|
7. **`billing.module.ts`** - Remove `BillingOrchestrator` from providers/exports
|
||||||
|
|
||||||
|
### Portal Files to EDIT
|
||||||
|
|
||||||
|
8. **`InvoiceList.tsx`** - Remove dual-mode logic:
|
||||||
|
- Remove `subscriptionId`, `showFilters` props
|
||||||
|
- Remove `useSubscriptionInvoices` import
|
||||||
|
- Remove `useInvoicesData()` helper, use `useInvoices()` directly
|
||||||
|
- Remove `isSubscriptionMode` conditionals
|
||||||
|
- Simplify `InvoicesFilterBar` (remove conditional spread)
|
||||||
|
|
||||||
|
9. **`SubscriptionDetail.tsx`** - Replace `<InvoicesList>` with billing link:
|
||||||
|
- Remove `InvoicesList` and `InvoiceListSkeleton` imports
|
||||||
|
- Replace billing history section with "View all invoices" link to `/account/billing`
|
||||||
|
|
||||||
|
10. **`app/account/subscriptions/[id]/loading.tsx`** - Remove `InvoiceListSkeleton`
|
||||||
|
|
||||||
|
11. **`useSubscriptions.ts`** - Remove `useSubscriptionInvoices()` hook
|
||||||
|
|
||||||
|
12. **`core/api/index.ts`** - Remove `subscriptions.invoices` query key
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Eliminates 20+ WHMCS API calls per subscription detail view
|
||||||
|
- Removes ~300 lines of scanning/caching/batching code
|
||||||
|
- Simplifies InvoicesList to single-purpose component
|
||||||
|
- Removes unused service abstraction layer (BillingOrchestrator)
|
||||||
|
- No feature regression: invoice data still accessible via main billing page
|
||||||
Loading…
x
Reference in New Issue
Block a user