- Streamlined the README.md for clarity and conciseness. - Deleted outdated documentation files related to Freebit SIM management, SIM management API data flow, and various architectural guides to reduce clutter and improve maintainability. - Updated the last modified date in the README to reflect the latest changes.
446 lines
12 KiB
Markdown
446 lines
12 KiB
Markdown
# Recommended Portal Structure: `lib/` vs `features/`
|
|
|
|
## 🎯 Core Principle: Co-location
|
|
|
|
**Feature-specific code belongs WITH the feature, not in a centralized `lib/` folder.**
|
|
|
|
---
|
|
|
|
## 📂 Proposed Structure
|
|
|
|
```
|
|
apps/portal/src/
|
|
├── lib/ # ✅ ONLY truly generic utilities
|
|
│ ├── api/
|
|
│ │ ├── client.ts # API client instance & configuration
|
|
│ │ ├── query-keys.ts # React Query key factory
|
|
│ │ ├── helpers.ts # getDataOrThrow, getDataOrDefault
|
|
│ │ └── index.ts # Barrel export
|
|
│ │
|
|
│ ├── utils/
|
|
│ │ ├── cn.ts # Tailwind className utility (used everywhere)
|
|
│ │ ├── error-handling.ts # Generic error handling (used everywhere)
|
|
│ │ └── index.ts
|
|
│ │
|
|
│ ├── providers.tsx # Root-level React context providers
|
|
│ └── index.ts # Main barrel export
|
|
│
|
|
└── features/ # ✅ Feature-specific code lives here
|
|
├── billing/
|
|
│ ├── components/
|
|
│ │ ├── InvoiceList.tsx
|
|
│ │ └── InvoiceCard.tsx
|
|
│ ├── hooks/
|
|
│ │ └── useBilling.ts # ← Feature-specific hooks HERE
|
|
│ ├── utils/
|
|
│ │ └── invoice-helpers.ts # ← Feature-specific utilities HERE
|
|
│ └── index.ts
|
|
│
|
|
├── subscriptions/
|
|
│ ├── components/
|
|
│ ├── hooks/
|
|
│ │ └── useSubscriptions.ts
|
|
│ └── index.ts
|
|
│
|
|
├── orders/
|
|
│ ├── components/
|
|
│ ├── hooks/
|
|
│ │ └── useOrders.ts
|
|
│ └── index.ts
|
|
│
|
|
└── auth/
|
|
├── components/
|
|
├── hooks/
|
|
│ └── useAuth.ts
|
|
├── services/
|
|
│ └── auth.store.ts
|
|
└── index.ts
|
|
```
|
|
|
|
---
|
|
|
|
## 🎨 What Goes Where?
|
|
|
|
### ✅ `lib/` - Truly Generic, Reusable Across Features
|
|
|
|
| File | Purpose | Used By |
|
|
| ----------------------------- | ------------------------------------ | -------------- |
|
|
| `lib/api/client.ts` | API client instance | All features |
|
|
| `lib/api/query-keys.ts` | React Query keys factory | All features |
|
|
| `lib/api/helpers.ts` | `getDataOrThrow`, `getDataOrDefault` | All features |
|
|
| `lib/utils/cn.ts` | Tailwind className merger | All components |
|
|
| `lib/utils/error-handling.ts` | Generic error parsing | All features |
|
|
| `lib/providers.tsx` | Root providers (QueryClient, Theme) | App root |
|
|
|
|
### ✅ `features/*/hooks/` - Feature-Specific Hooks
|
|
|
|
| File | Purpose | Used By |
|
|
| -------------------------------------------------- | --------------------------- | ----------------------- |
|
|
| `features/billing/hooks/useBilling.ts` | Invoice queries & mutations | Billing pages only |
|
|
| `features/subscriptions/hooks/useSubscriptions.ts` | Subscription queries | Subscription pages only |
|
|
| `features/orders/hooks/useOrders.ts` | Order queries | Order pages only |
|
|
| `features/auth/hooks/useAuth.ts` | Auth state & actions | Auth-related components |
|
|
|
|
---
|
|
|
|
## ❌ Anti-Pattern: Centralized Feature Hooks
|
|
|
|
```
|
|
lib/
|
|
├── hooks/
|
|
│ ├── use-billing.ts # ❌ BAD - billing-specific
|
|
│ ├── use-subscriptions.ts # ❌ BAD - subscriptions-specific
|
|
│ └── use-orders.ts # ❌ BAD - orders-specific
|
|
```
|
|
|
|
**Why this is bad:**
|
|
|
|
- Hard to find (is it in `lib` or `features`?)
|
|
- Breaks feature encapsulation
|
|
- Harder to delete features
|
|
- Makes the `lib` folder bloated
|
|
|
|
---
|
|
|
|
## 📝 Implementation
|
|
|
|
### 1. **`lib/api/index.ts`** - Clean API Exports
|
|
|
|
```typescript
|
|
/**
|
|
* API Client Utilities
|
|
* Central export for all API-related functionality
|
|
*/
|
|
|
|
export { apiClient } from "./client";
|
|
export { queryKeys } from "./query-keys";
|
|
export { getDataOrThrow, getDataOrDefault, isApiError } from "./helpers";
|
|
|
|
// Re-export common types from generated client
|
|
export type { QueryParams, PathParams } from "./runtime/client";
|
|
```
|
|
|
|
### 2. **`lib/api/helpers.ts`** - API Helper Functions
|
|
|
|
```typescript
|
|
import type { ApiResponse } from "@customer-portal/domain/common";
|
|
|
|
export function getDataOrThrow<T>(
|
|
response: { data?: T; error?: unknown },
|
|
errorMessage: string
|
|
): T {
|
|
if (response.error || !response.data) {
|
|
throw new Error(errorMessage);
|
|
}
|
|
return response.data;
|
|
}
|
|
|
|
export function getDataOrDefault<T>(response: { data?: T; error?: unknown }, defaultValue: T): T {
|
|
return response.data ?? defaultValue;
|
|
}
|
|
|
|
export function isApiError(error: unknown): error is Error {
|
|
return error instanceof Error;
|
|
}
|
|
```
|
|
|
|
### 3. **`features/billing/hooks/useBilling.ts`** - Clean Hook Implementation
|
|
|
|
```typescript
|
|
"use client";
|
|
|
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
|
import { apiClient, queryKeys, getDataOrThrow, getDataOrDefault } from "@/lib/api";
|
|
import type { QueryParams } from "@/lib/api";
|
|
|
|
// ✅ Single consolidated import from domain
|
|
import {
|
|
// Types
|
|
type Invoice,
|
|
type InvoiceList,
|
|
type InvoiceQueryParams,
|
|
type InvoiceSsoLink,
|
|
type PaymentMethodList,
|
|
// Schemas
|
|
invoiceSchema,
|
|
invoiceListSchema,
|
|
// Constants
|
|
INVOICE_STATUS,
|
|
type InvoiceStatus,
|
|
} from "@customer-portal/domain/billing";
|
|
|
|
// Constants
|
|
const EMPTY_INVOICE_LIST: InvoiceList = {
|
|
invoices: [],
|
|
pagination: { page: 1, totalItems: 0, totalPages: 0 },
|
|
};
|
|
|
|
const EMPTY_PAYMENT_METHODS: PaymentMethodList = {
|
|
paymentMethods: [],
|
|
totalCount: 0,
|
|
};
|
|
|
|
// Helper functions
|
|
function ensureInvoiceStatus(invoice: Invoice): Invoice {
|
|
return {
|
|
...invoice,
|
|
status: (invoice.status as InvoiceStatus) ?? INVOICE_STATUS.DRAFT,
|
|
};
|
|
}
|
|
|
|
function normalizeInvoiceList(list: InvoiceList): InvoiceList {
|
|
return {
|
|
...list,
|
|
invoices: list.invoices.map(ensureInvoiceStatus),
|
|
pagination: {
|
|
page: list.pagination?.page ?? 1,
|
|
totalItems: list.pagination?.totalItems ?? 0,
|
|
totalPages: list.pagination?.totalPages ?? 0,
|
|
nextCursor: list.pagination?.nextCursor,
|
|
},
|
|
};
|
|
}
|
|
|
|
function toQueryParams(params: InvoiceQueryParams): QueryParams {
|
|
return Object.entries(params).reduce((acc, [key, value]) => {
|
|
if (value !== undefined) {
|
|
acc[key] = value;
|
|
}
|
|
return acc;
|
|
}, {} as QueryParams);
|
|
}
|
|
|
|
// API functions (keep as internal implementation details)
|
|
async function fetchInvoices(params?: InvoiceQueryParams): Promise<InvoiceList> {
|
|
const query = params ? toQueryParams(params) : undefined;
|
|
const response = await apiClient.GET<InvoiceList>(
|
|
"/api/invoices",
|
|
query ? { params: { query } } : undefined
|
|
);
|
|
const data = getDataOrDefault(response, EMPTY_INVOICE_LIST);
|
|
const parsed = invoiceListSchema.parse(data);
|
|
return normalizeInvoiceList(parsed);
|
|
}
|
|
|
|
async function fetchInvoice(id: string): Promise<Invoice> {
|
|
const response = await apiClient.GET<Invoice>("/api/invoices/{id}", {
|
|
params: { path: { id } },
|
|
});
|
|
const invoice = getDataOrThrow(response, "Invoice not found");
|
|
const parsed = invoiceSchema.parse(invoice);
|
|
return ensureInvoiceStatus(parsed);
|
|
}
|
|
|
|
async function fetchPaymentMethods(): Promise<PaymentMethodList> {
|
|
const response = await apiClient.GET<PaymentMethodList>("/api/invoices/payment-methods");
|
|
return getDataOrDefault(response, EMPTY_PAYMENT_METHODS);
|
|
}
|
|
|
|
// Exported hooks
|
|
export function useInvoices(params?: InvoiceQueryParams) {
|
|
return useQuery({
|
|
queryKey: queryKeys.billing.invoices(params),
|
|
queryFn: () => fetchInvoices(params),
|
|
});
|
|
}
|
|
|
|
export function useInvoice(id: string, enabled = true) {
|
|
return useQuery({
|
|
queryKey: queryKeys.billing.invoice(id),
|
|
queryFn: () => fetchInvoice(id),
|
|
enabled: Boolean(id) && enabled,
|
|
});
|
|
}
|
|
|
|
export function usePaymentMethods() {
|
|
return useQuery({
|
|
queryKey: queryKeys.billing.paymentMethods(),
|
|
queryFn: fetchPaymentMethods,
|
|
});
|
|
}
|
|
|
|
export function useCreateInvoiceSsoLink() {
|
|
return useMutation({
|
|
mutationFn: async ({
|
|
invoiceId,
|
|
target,
|
|
}: {
|
|
invoiceId: number;
|
|
target?: "view" | "download" | "pay";
|
|
}) => {
|
|
const response = await apiClient.POST<InvoiceSsoLink>("/api/invoices/{id}/sso-link", {
|
|
params: {
|
|
path: { id: invoiceId },
|
|
query: target ? { target } : undefined,
|
|
},
|
|
});
|
|
return getDataOrThrow(response, "Failed to create SSO link");
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useCreatePaymentMethodsSsoLink() {
|
|
return useMutation({
|
|
mutationFn: async () => {
|
|
const response = await apiClient.POST<InvoiceSsoLink>("/api/auth/sso-link", {
|
|
body: { destination: "index.php?rp=/account/paymentmethods" },
|
|
});
|
|
return getDataOrThrow(response, "Failed to create payment methods SSO link");
|
|
},
|
|
});
|
|
}
|
|
```
|
|
|
|
### 4. **`features/billing/index.ts`** - Feature Barrel Export
|
|
|
|
```typescript
|
|
/**
|
|
* Billing Feature Exports
|
|
*/
|
|
|
|
// Hooks
|
|
export * from "./hooks/useBilling";
|
|
|
|
// Components (if you want to export them)
|
|
export * from "./components/InvoiceList";
|
|
export * from "./components/InvoiceCard";
|
|
```
|
|
|
|
### 5. **`lib/index.ts`** - Generic Utilities Only
|
|
|
|
```typescript
|
|
/**
|
|
* Portal Library
|
|
* ONLY generic utilities used across features
|
|
*/
|
|
|
|
// API utilities (used by all features)
|
|
export * from "./api";
|
|
|
|
// Generic utilities (used by all features)
|
|
export * from "./utils";
|
|
|
|
// NOTE: Feature-specific hooks are NOT exported here
|
|
// Import them from their respective features:
|
|
// import { useInvoices } from "@/features/billing";
|
|
```
|
|
|
|
---
|
|
|
|
## 🎯 Usage After Restructure
|
|
|
|
### In Billing Pages/Components
|
|
|
|
```typescript
|
|
// ✅ Import from feature
|
|
import { useInvoices, useInvoice, usePaymentMethods } from "@/features/billing";
|
|
|
|
// Or direct import
|
|
import { useInvoices } from "@/features/billing/hooks/useBilling";
|
|
|
|
function InvoicesPage() {
|
|
const { data: invoices, isLoading } = useInvoices({ status: "Unpaid" });
|
|
|
|
// ...
|
|
}
|
|
```
|
|
|
|
### In Feature Hooks (within features/billing/hooks/)
|
|
|
|
```typescript
|
|
// ✅ Import generic utilities from lib
|
|
import { apiClient, queryKeys, getDataOrThrow } from "@/lib/api";
|
|
|
|
// ✅ Import domain types
|
|
import {
|
|
type Invoice,
|
|
type InvoiceList,
|
|
invoiceSchema,
|
|
type InvoiceQueryParams,
|
|
} from "@customer-portal/domain/billing";
|
|
|
|
export function useInvoices(params?: InvoiceQueryParams) {
|
|
// ...
|
|
}
|
|
```
|
|
|
|
### In Other Feature Hooks (features/subscriptions/hooks/)
|
|
|
|
```typescript
|
|
// ✅ Generic utilities from lib
|
|
import { apiClient, queryKeys, getDataOrThrow } from "@/lib/api";
|
|
|
|
// ✅ Domain types for subscriptions
|
|
import {
|
|
type Subscription,
|
|
subscriptionSchema,
|
|
type SubscriptionQueryParams,
|
|
} from "@customer-portal/domain/subscriptions";
|
|
|
|
export function useSubscriptions(params?: SubscriptionQueryParams) {
|
|
// ...
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 📋 Benefits of This Structure
|
|
|
|
### 1. **Clear Separation of Concerns**
|
|
|
|
- `api/` - HTTP client & infrastructure
|
|
- `hooks/` - React Query abstractions
|
|
- `utils/` - Helper functions
|
|
|
|
### 2. **Clean Imports**
|
|
|
|
```typescript
|
|
// ❌ Before: Messy
|
|
import { apiClient, queryKeys, getDataOrDefault, getDataOrThrow } from "@/lib/api";
|
|
import type { QueryParams } from "@/lib/api/runtime/client";
|
|
import { Invoice, InvoiceList } from "@customer-portal/domain/billing";
|
|
import { invoiceSchema } from "@customer-portal/domain/validation/shared/entities";
|
|
import { INVOICE_STATUS } from "@customer-portal/domain/billing";
|
|
|
|
// ✅ After: Clean
|
|
import { apiClient, queryKeys, getDataOrThrow, type QueryParams } from "@/lib/api";
|
|
import {
|
|
type Invoice,
|
|
type InvoiceList,
|
|
invoiceSchema,
|
|
INVOICE_STATUS,
|
|
} from "@customer-portal/domain/billing";
|
|
```
|
|
|
|
### 3. **Easy to Find Things**
|
|
|
|
- Need a query hook? → `lib/hooks/queries/`
|
|
- Need API utilities? → `lib/api/`
|
|
- Need to update a domain type? → `packages/domain/billing/`
|
|
|
|
### 4. **Testable**
|
|
|
|
Each piece can be tested independently:
|
|
|
|
- API helpers are pure functions
|
|
- Hooks can be tested with React Testing Library
|
|
- Domain logic is already in domain package
|
|
|
|
---
|
|
|
|
## 🔧 What About That Weird Validation Import?
|
|
|
|
```typescript
|
|
// ❌ This should NOT exist
|
|
import { invoiceSchema } from "@customer-portal/domain/validation/shared/entities";
|
|
```
|
|
|
|
This path suggests you might have old validation code. The schema should be:
|
|
|
|
```typescript
|
|
// ✅ Correct
|
|
import { invoiceSchema } from "@customer-portal/domain/billing";
|
|
```
|
|
|
|
Let me check if this old validation path exists and needs cleanup.
|