459 lines
14 KiB
Markdown
459 lines
14 KiB
Markdown
|
|
# Sidebar Navigation Consolidation
|
||
|
|
|
||
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||
|
|
|
||
|
|
**Goal:** Simplify sidebar by removing expandable sub-menus — make Support and Billing flat links, creating a combined Billing page.
|
||
|
|
|
||
|
|
**Architecture:** Remove children from navigation items, create a new combined Billing page view that composes existing PaymentMethods and InvoicesList components, update route structure to add `/account/billing` page.
|
||
|
|
|
||
|
|
**Tech Stack:** Next.js 15, React 19, Tailwind CSS, existing billing/support feature components
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 1: Make Support a flat sidebar link
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
|
||
|
|
- Modify: `apps/portal/src/components/organisms/AppShell/navigation.ts:46-49`
|
||
|
|
|
||
|
|
**Step 1: Update navigation config**
|
||
|
|
|
||
|
|
Change the Support entry from expandable (with children) to a flat link:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
// Before (lines 46-49):
|
||
|
|
{
|
||
|
|
name: "Support",
|
||
|
|
icon: ChatBubbleLeftRightIcon,
|
||
|
|
children: [
|
||
|
|
{ name: "Cases", href: "/account/support" },
|
||
|
|
{ name: "New Case", href: "/account/support/new" },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
|
||
|
|
// After:
|
||
|
|
{ name: "Support", href: "/account/support", icon: ChatBubbleLeftRightIcon },
|
||
|
|
```
|
||
|
|
|
||
|
|
**Step 2: Remove auto-expand logic for Support in AppShell**
|
||
|
|
|
||
|
|
File: `apps/portal/src/components/organisms/AppShell/AppShell.tsx:112`
|
||
|
|
|
||
|
|
Remove the line:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
if (pathname.startsWith("/account/support")) next.add("Support");
|
||
|
|
```
|
||
|
|
|
||
|
|
**Step 3: Verify**
|
||
|
|
|
||
|
|
Run: `pnpm type-check`
|
||
|
|
Expected: PASS — no type errors
|
||
|
|
|
||
|
|
**Step 4: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add apps/portal/src/components/organisms/AppShell/navigation.ts apps/portal/src/components/organisms/AppShell/AppShell.tsx
|
||
|
|
git commit -m "refactor: make Support a flat sidebar link"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 2: Create combined Billing page view
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
|
||
|
|
- Create: `apps/portal/src/features/billing/views/BillingOverview.tsx`
|
||
|
|
|
||
|
|
**Step 1: Create the combined billing view**
|
||
|
|
|
||
|
|
This view composes existing `PaymentMethodsContainer` content and `InvoicesList` into one page. We reuse the existing components directly — payment methods section on top, invoices below.
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
"use client";
|
||
|
|
|
||
|
|
import { useState } from "react";
|
||
|
|
import { CreditCardIcon } from "@heroicons/react/24/outline";
|
||
|
|
import { PageLayout } from "@/components/templates/PageLayout";
|
||
|
|
import { ErrorBoundary } from "@/components/molecules";
|
||
|
|
import { useSession } from "@/features/auth/hooks";
|
||
|
|
import { useAuthStore } from "@/features/auth/stores/auth.store";
|
||
|
|
import { isApiError } from "@/core/api";
|
||
|
|
import { openSsoLink } from "@/features/billing/utils/sso";
|
||
|
|
import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh";
|
||
|
|
import {
|
||
|
|
PaymentMethodCard,
|
||
|
|
usePaymentMethods,
|
||
|
|
useCreatePaymentMethodsSsoLink,
|
||
|
|
} from "@/features/billing";
|
||
|
|
import type { PaymentMethodList } from "@customer-portal/domain/payments";
|
||
|
|
import { InlineToast } from "@/components/atoms/inline-toast";
|
||
|
|
import { Button } from "@/components/atoms/button";
|
||
|
|
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
||
|
|
import { InvoicesList } from "@/features/billing/components/InvoiceList/InvoiceList";
|
||
|
|
import { logger } from "@/core/logger";
|
||
|
|
|
||
|
|
function PaymentMethodsSkeleton() {
|
||
|
|
return (
|
||
|
|
<div className="bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] p-6">
|
||
|
|
<div className="flex items-center justify-between mb-6">
|
||
|
|
<Skeleton className="h-6 w-48" />
|
||
|
|
<Skeleton className="h-10 w-32" />
|
||
|
|
</div>
|
||
|
|
<div className="space-y-4">
|
||
|
|
{Array.from({ length: 2 }).map((_, i) => (
|
||
|
|
<div key={i} className="bg-muted rounded-lg p-4">
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<div className="flex items-center gap-4">
|
||
|
|
<Skeleton className="h-12 w-12 rounded-lg" />
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Skeleton className="h-5 w-40" />
|
||
|
|
<Skeleton className="h-4 w-24" />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<Skeleton className="h-9 w-28" />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function PaymentMethodsSection({
|
||
|
|
paymentMethodsData,
|
||
|
|
onManage,
|
||
|
|
isPending,
|
||
|
|
}: {
|
||
|
|
paymentMethodsData: PaymentMethodList;
|
||
|
|
onManage: () => void;
|
||
|
|
isPending: boolean;
|
||
|
|
}) {
|
||
|
|
const hasMethods = paymentMethodsData.paymentMethods.length > 0;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] overflow-hidden">
|
||
|
|
<div className="px-6 py-5 border-b border-border">
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<div>
|
||
|
|
<h2 className="text-lg font-semibold text-foreground">Payment Methods</h2>
|
||
|
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||
|
|
{hasMethods
|
||
|
|
? `${paymentMethodsData.paymentMethods.length} payment method${paymentMethodsData.paymentMethods.length === 1 ? "" : "s"} on file`
|
||
|
|
: "No payment methods on file"}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
<Button onClick={onManage} disabled={isPending} size="default">
|
||
|
|
{isPending ? "Opening..." : "Manage Cards"}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
{hasMethods && (
|
||
|
|
<div className="p-6">
|
||
|
|
<div className="space-y-4">
|
||
|
|
{paymentMethodsData.paymentMethods.map(paymentMethod => (
|
||
|
|
<PaymentMethodCard key={paymentMethod.id} paymentMethod={paymentMethod} />
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function BillingOverview() {
|
||
|
|
const [error, setError] = useState<string | null>(null);
|
||
|
|
const { isAuthenticated } = useSession();
|
||
|
|
const paymentMethodsQuery = usePaymentMethods();
|
||
|
|
const {
|
||
|
|
data: paymentMethodsData,
|
||
|
|
isLoading: isLoadingPaymentMethods,
|
||
|
|
isFetching: isFetchingPaymentMethods,
|
||
|
|
error: paymentMethodsError,
|
||
|
|
} = paymentMethodsQuery;
|
||
|
|
const createPaymentMethodsSsoLink = useCreatePaymentMethodsSsoLink();
|
||
|
|
const { hasCheckedAuth } = useAuthStore();
|
||
|
|
|
||
|
|
const paymentRefresh = usePaymentRefresh({
|
||
|
|
refetch: async () => {
|
||
|
|
const result = await paymentMethodsQuery.refetch();
|
||
|
|
return { data: result.data };
|
||
|
|
},
|
||
|
|
hasMethods: data => Boolean(data && (data.totalCount > 0 || data.paymentMethods.length > 0)),
|
||
|
|
attachFocusListeners: true,
|
||
|
|
});
|
||
|
|
|
||
|
|
const openPaymentMethods = async () => {
|
||
|
|
if (!isAuthenticated) {
|
||
|
|
setError("Please log in to access payment methods.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
setError(null);
|
||
|
|
try {
|
||
|
|
const ssoLink = await createPaymentMethodsSsoLink.mutateAsync();
|
||
|
|
openSsoLink(ssoLink.url, { newTab: true });
|
||
|
|
} catch (err: unknown) {
|
||
|
|
logger.error("Failed to open payment methods", err);
|
||
|
|
if (
|
||
|
|
isApiError(err) &&
|
||
|
|
"response" in err &&
|
||
|
|
typeof err.response === "object" &&
|
||
|
|
err.response !== null &&
|
||
|
|
"status" in err.response &&
|
||
|
|
err.response.status === 401
|
||
|
|
) {
|
||
|
|
setError("Authentication failed. Please log in again.");
|
||
|
|
} else {
|
||
|
|
setError("Unable to access payment methods. Please try again later.");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const isPaymentLoading = !hasCheckedAuth || isLoadingPaymentMethods || isFetchingPaymentMethods;
|
||
|
|
const combinedError = error
|
||
|
|
? new Error(error)
|
||
|
|
: paymentMethodsError instanceof Error
|
||
|
|
? paymentMethodsError
|
||
|
|
: paymentMethodsError
|
||
|
|
? new Error(String(paymentMethodsError))
|
||
|
|
: null;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<PageLayout icon={<CreditCardIcon />} title="Billing" error={combinedError}>
|
||
|
|
<ErrorBoundary>
|
||
|
|
<InlineToast
|
||
|
|
visible={paymentRefresh.toast.visible}
|
||
|
|
text={paymentRefresh.toast.text}
|
||
|
|
tone={paymentRefresh.toast.tone}
|
||
|
|
/>
|
||
|
|
|
||
|
|
<div className="space-y-8">
|
||
|
|
{/* Payment Methods Section */}
|
||
|
|
{isPaymentLoading && <PaymentMethodsSkeleton />}
|
||
|
|
{!isPaymentLoading && paymentMethodsData && (
|
||
|
|
<PaymentMethodsSection
|
||
|
|
paymentMethodsData={paymentMethodsData}
|
||
|
|
onManage={() => void openPaymentMethods()}
|
||
|
|
isPending={createPaymentMethodsSsoLink.isPending}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Invoices Section */}
|
||
|
|
<div>
|
||
|
|
<h2 className="text-lg font-semibold text-foreground mb-4">Invoices</h2>
|
||
|
|
<InvoicesList />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</ErrorBoundary>
|
||
|
|
</PageLayout>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export default BillingOverview;
|
||
|
|
```
|
||
|
|
|
||
|
|
**Step 2: Verify**
|
||
|
|
|
||
|
|
Run: `pnpm type-check`
|
||
|
|
Expected: PASS
|
||
|
|
|
||
|
|
**Step 3: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add apps/portal/src/features/billing/views/BillingOverview.tsx
|
||
|
|
git commit -m "feat: create combined BillingOverview view"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 3: Add /account/billing route and make sidebar flat
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
|
||
|
|
- Create: `apps/portal/src/app/account/billing/page.tsx`
|
||
|
|
- Modify: `apps/portal/src/components/organisms/AppShell/navigation.ts:29-36`
|
||
|
|
- Modify: `apps/portal/src/components/organisms/AppShell/AppShell.tsx:111`
|
||
|
|
|
||
|
|
**Step 1: Create the billing page**
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
import { BillingOverview } from "@/features/billing/views/BillingOverview";
|
||
|
|
|
||
|
|
export default function AccountBillingPage() {
|
||
|
|
return <BillingOverview />;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Step 2: Update navigation config**
|
||
|
|
|
||
|
|
Change Billing from expandable to flat:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
// Before (lines 29-36):
|
||
|
|
{
|
||
|
|
name: "Billing",
|
||
|
|
icon: CreditCardIcon,
|
||
|
|
children: [
|
||
|
|
{ name: "Invoices", href: "/account/billing/invoices" },
|
||
|
|
{ name: "Payment Methods", href: "/account/billing/payments" },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
|
||
|
|
// After:
|
||
|
|
{ name: "Billing", href: "/account/billing", icon: CreditCardIcon },
|
||
|
|
```
|
||
|
|
|
||
|
|
**Step 3: Remove auto-expand logic for Billing in AppShell**
|
||
|
|
|
||
|
|
File: `apps/portal/src/components/organisms/AppShell/AppShell.tsx`
|
||
|
|
|
||
|
|
Remove the line:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
if (pathname.startsWith("/account/billing")) next.add("Billing");
|
||
|
|
```
|
||
|
|
|
||
|
|
**Step 4: Verify**
|
||
|
|
|
||
|
|
Run: `pnpm type-check`
|
||
|
|
Expected: PASS
|
||
|
|
|
||
|
|
**Step 5: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add apps/portal/src/app/account/billing/page.tsx apps/portal/src/components/organisms/AppShell/navigation.ts apps/portal/src/components/organisms/AppShell/AppShell.tsx
|
||
|
|
git commit -m "refactor: make Billing a flat sidebar link with combined page"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 4: Update Sidebar active-state matching for flat Billing and Support
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
|
||
|
|
- Modify: `apps/portal/src/components/organisms/AppShell/Sidebar.tsx:327`
|
||
|
|
|
||
|
|
The current `SimpleNavItem` uses exact match (`pathname === item.href`) which won't highlight Billing when on `/account/billing/invoices/123`. Change to `startsWith` for path-based matching:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
// Before (line 327):
|
||
|
|
const isActive = item.href ? pathname === item.href : false;
|
||
|
|
|
||
|
|
// After:
|
||
|
|
const isActive = item.href
|
||
|
|
? item.href === "/account"
|
||
|
|
? pathname === item.href
|
||
|
|
: pathname.startsWith(item.href)
|
||
|
|
: false;
|
||
|
|
```
|
||
|
|
|
||
|
|
This ensures:
|
||
|
|
|
||
|
|
- Dashboard (`/account`) still uses exact match (doesn't highlight for every `/account/*` page)
|
||
|
|
- Billing (`/account/billing`) highlights on `/account/billing`, `/account/billing/invoices/123`, etc.
|
||
|
|
- Support (`/account/support`) highlights on `/account/support`, `/account/support/new`, `/account/support/123`, etc.
|
||
|
|
|
||
|
|
**Step 1: Update Sidebar active matching**
|
||
|
|
|
||
|
|
Apply the change above.
|
||
|
|
|
||
|
|
**Step 2: Verify**
|
||
|
|
|
||
|
|
Run: `pnpm type-check`
|
||
|
|
Expected: PASS
|
||
|
|
|
||
|
|
**Step 3: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add apps/portal/src/components/organisms/AppShell/Sidebar.tsx
|
||
|
|
git commit -m "fix: use startsWith for sidebar active state on nested routes"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 5: Update backLink references in InvoiceDetail
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
|
||
|
|
- Modify: `apps/portal/src/features/billing/views/InvoiceDetail.tsx:84,99`
|
||
|
|
|
||
|
|
Update the "Back to Invoices" links to point to the combined billing page:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
// Before:
|
||
|
|
backLink={{ label: "Back to Invoices", href: "/account/billing/invoices" }}
|
||
|
|
|
||
|
|
// After:
|
||
|
|
backLink={{ label: "Back to Billing", href: "/account/billing" }}
|
||
|
|
```
|
||
|
|
|
||
|
|
Apply this on both lines 84 and 99.
|
||
|
|
|
||
|
|
**Step 1: Apply changes**
|
||
|
|
|
||
|
|
**Step 2: Verify**
|
||
|
|
|
||
|
|
Run: `pnpm type-check`
|
||
|
|
Expected: PASS
|
||
|
|
|
||
|
|
**Step 3: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add apps/portal/src/features/billing/views/InvoiceDetail.tsx
|
||
|
|
git commit -m "fix: update InvoiceDetail backLink to point to combined billing page"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 6: Update remaining billing route references
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
|
||
|
|
- Modify: `apps/portal/src/features/dashboard/utils/dashboard.utils.ts:43` — change `/account/billing/invoices/${activity.relatedId}` to keep as-is (invoice detail pages still live at `/account/billing/invoices/[id]`)
|
||
|
|
- Modify: `apps/portal/src/features/dashboard/components/TaskList.tsx:66` — change `href="/account/billing/invoices"` to `href="/account/billing"`
|
||
|
|
- Modify: `apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx:208` — change `href="/account/billing/invoices"` to `href="/account/billing"`
|
||
|
|
- Modify: `apps/portal/src/features/billing/components/BillingSummary/BillingSummary.tsx:160,187` — change `href="/account/billing/invoices"` to `href="/account/billing"`
|
||
|
|
|
||
|
|
Note: Keep `dashboard.utils.ts:43` unchanged — it links to a specific invoice detail page which still exists at `/account/billing/invoices/[id]`.
|
||
|
|
Note: Keep `InvoiceTable.tsx:276` unchanged — it navigates to individual invoice detail pages.
|
||
|
|
|
||
|
|
**Step 1: Apply changes to the 3 files listed above**
|
||
|
|
|
||
|
|
**Step 2: Verify**
|
||
|
|
|
||
|
|
Run: `pnpm type-check`
|
||
|
|
Expected: PASS
|
||
|
|
|
||
|
|
**Step 3: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add apps/portal/src/features/dashboard/components/TaskList.tsx apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx apps/portal/src/features/billing/components/BillingSummary/BillingSummary.tsx
|
||
|
|
git commit -m "fix: update billing route references to use combined billing page"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 7: Clean up unused imports in navigation.ts
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
|
||
|
|
- Modify: `apps/portal/src/components/organisms/AppShell/navigation.ts`
|
||
|
|
|
||
|
|
After removing children from both Billing and Support, the `NavigationChild` type and children-related interfaces may still be needed by other code (the Sidebar still supports expandable items generically). Check if `NavigationChild` is still used — if Subscriptions or any other item still has children, keep it. If no items have children anymore, remove unused types.
|
||
|
|
|
||
|
|
**Step 1: Check if any navigation item still uses children**
|
||
|
|
|
||
|
|
After our changes, review `baseNavigation` — none will have children. But `NavigationChild` and `children` field on `NavigationItem` are still referenced by `Sidebar.tsx` (the `ExpandableNavItem` component). These can stay for now since they're part of the generic nav system — removing the component is a larger cleanup.
|
||
|
|
|
||
|
|
**Step 2: Verify full build**
|
||
|
|
|
||
|
|
Run: `pnpm type-check && pnpm lint`
|
||
|
|
Expected: PASS
|
||
|
|
|
||
|
|
**Step 3: Final commit if any cleanup was needed**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add -A
|
||
|
|
git commit -m "chore: sidebar consolidation cleanup"
|
||
|
|
```
|