Code Standards — Frontend & Workflow
Project: PTX Channel Manager (ptx-cm)
Last Updated: 2026-06-05
See also: code-standards.md (principles & naming)
1. Frontend (Next.js/React) Standards
Component File Structure
// ✓ Good: Typed props, separated concerns, named export
'use client';
import { useForm } from 'react-hook-form';
interface PropertyFormProps {
onSubmit: (data: CreatePropertyDto) => Promise<void>;
loading?: boolean;
}
export function PropertyForm({ onSubmit, loading }: PropertyFormProps) {
const { register, handleSubmit, formState: { errors } } = useForm<CreatePropertyDto>();
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} />
{errors.name && <span>{errors.name.message}</span>}
<button type="submit" disabled={loading}>Create</button>
</form>
);
}
// ✗ Bad: All logic inlined, no types, default export only
export default function Page() {
const [properties, setProperties] = useState([]);
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({});
// ... 300 lines of inline logic and JSX
}Data Fetching: SWR via useApiGet
// ✓ Good: SWR hook with dedup + loading state
import { useApiGet } from '@/lib/use-api';
export function PropertyList() {
const { data: properties, isLoading } = useApiGet<Property[]>('properties', {
dedupingInterval: 30_000,
});
if (isLoading) return <LoadingSkeleton />;
if (!properties?.length) return <EmptyState />;
return <DataTable data={properties} columns={columns} />;
}
// ✗ Bad: Inline fetch in useEffect, no dedup
export function PropertyList() {
const [properties, setProperties] = useState([]);
useEffect(() => {
fetch('/api/v1/properties')
.then(r => r.json())
.then(setProperties); // ❌ No loading state, no dedup, no error handling
}, []);
}API Client Pattern
// ✓ Good: Centralized apiFetch with 401 auto-retry
import { apiFetch } from '@/lib/api-client';
export const propertyApi = {
list: (params?: ListPropertiesDto) => apiFetch<PaginatedResponse<Property>>('properties', { params }),
create: (dto: CreatePropertyDto) => apiFetch<Property>('properties', { method: 'POST', body: dto }),
get: (id: string) => apiFetch<Property>(`properties/${id}`),
};
// ✗ Bad: Inline fetch calls duplicated across components
export function PropertyList() {
useEffect(() => {
fetch('/api/v1/properties') // ❌ Repeated in multiple places, no error handling
.then(r => r.json())
.then(setProperties);
}, []);
}Context Provider Usage
// ✓ Good: Use typed context hooks
import { useAuth } from '@/lib/auth-context';
import { useCountry } from '@/lib/country-context';
export function BookingList() {
const { user } = useAuth();
const { country } = useCountry();
const { data } = useApiGet<Booking[]>(`bookings?country=${country}`);
// ...
}
// ✗ Bad: Reading context directly via useContext
import { AuthContext } from '@/lib/auth-context';
export function BookingList() {
const ctx = useContext(AuthContext); // ❌ No null check, not typed
const user = ctx?.user;
}Permission Checks
// ✓ Good: hasPermission helper from types package
import { hasPermission, Permission } from '@ptx-cm/types';
export function PropertyActions({ property }: { property: Property }) {
const { user } = useAuth();
const canEdit = hasPermission(user.permissions, 'PROPERTIES', Permission.EDIT);
const canDelete = hasPermission(user.permissions, 'PROPERTIES', Permission.DELETE);
return (
<>
{canEdit && <button onClick={onEdit}>Edit</button>}
{canDelete && <button onClick={onDelete}>Delete</button>}
</>
);
}
// ✗ Bad: Hardcoded role checks
export function PropertyActions() {
const { user } = useAuth();
if (user.roleName === 'manager') { // ❌ Brittle, bypasses permission bitmask
return <button>Edit</button>;
}
}Entity Routes via Typed Builders
See code-standards.md § 4 — Entity URLs & Internal Codes for full requirements.
Key points for frontend:
- Build ALL entity detail links via
lib/routes.tstyped builders — never hardcoded URL strings or raw UUIDs. - Builders enforce type safety: pass entity objects with
.internalCode(or.supplierCodefor suppliers,.codefor departments). - Display
.internalCodein UI, never the entity's UUID. - Old UUID URLs keep working (route params accept code OR UUID); new canonical URLs use internal codes.
Usage:
import { routes } from '@/lib/routes';
// ✓ CORRECT: Pass entity object with internal code
const property = { internalCode: 'MY-22846F', name: '...' };
const url = routes.property(property); // → '/properties/MY-22846F'
const booking = { internalCode: 'BK-7D21C4', ... };
router.push(routes.booking(booking)); // → '/bookings/BK-7D21C4'
// ✓ CORRECT: Display internal code in headers, links
<h1>{property.internalCode}</h1> // → 'MY-22846F'
<Link href={routes.property(property)}>Property</Link>
// ✗ NEVER: hardcoded strings or UUID display
<Link href={`/properties/${property.id}`}> // ❌ raw UUID
<h1>{booking.id.slice(0, 6)}</h1> // ❌ UUID fragment
router.push(`/customers/${uuid}`); // ❌ string interpolationCanonical redirect pattern (detail pages load via route param that may be UUID):
// In a detail page when loading an entity by idOrCode
const { data: property } = useApiGet(`properties/${idOrCode}`);
// Canonicalize to internal code if param differs
useEffect(() => {
if (property && idOrCode !== property.internalCode) {
router.replace(routes.property(property));
}
}, [property, idOrCode, router]);
### List Page Layout Pattern (FR-43)
All list/table pages follow a unified structure. See UI_SPEC.md §3.5 for full spec.
```typescript
// ✓ Good: Standard list page layout (matches bookings page)
export default function EntityListPage() {
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Zone 1: Fixed header bar */}
<header className="bg-card border-b border-border flex-shrink-0 flex items-center justify-between px-6 h-14 z-10">
<div className="flex items-center flex-1 gap-4">
<h1 className="text-lg font-bold text-text-primary tracking-tight mr-4 whitespace-nowrap">
Entity List
</h1>
<div className="relative max-w-md w-full">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-secondary" />
<Input placeholder="Search..." className="pl-9 w-full text-sm" />
</div>
</div>
<div className="flex items-center gap-3">
<Button variant="outline" size="sm">Export</Button>
<Button size="sm">+ Add</Button>
</div>
</header>
{/* Zone 2: Filter bar with chips */}
<div className="bg-card border-b border-border px-6 py-2.5 flex items-center gap-3 flex-wrap flex-shrink-0">
{hasActiveFilters && <FilterChip label="Status: Active" onRemove={clearFilter} />}
<Select className="w-auto text-xs py-1">{/* filter options */}</Select>
{hasActiveFilters && <button className="ml-auto text-xs text-[#16509c]">Clear All</button>}
</div>
{/* Zone 3: Scrollable table */}
<div className="flex-1 overflow-auto bg-card relative">
<table className="min-w-full border-collapse">
<thead className="sticky top-0 z-10">
<tr>
<th className="px-3 py-2 text-left text-[10px] font-semibold text-text-secondary uppercase tracking-wider border-b border-border bg-gray-50 dark:bg-slate-800/60">
Column
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
<tr className="hover:bg-gray-50 dark:hover:bg-slate-800/50 cursor-pointer">
<td className="px-3 py-1.5 whitespace-nowrap">Data</td>
</tr>
</tbody>
</table>
</div>
{/* Zone 4: Pagination footer */}
<footer className="bg-card border-t border-border px-6 py-3 flex items-center justify-between flex-shrink-0">
{/* Left: rows selector + showing text */}
{/* Right: prev/page numbers/next */}
</footer>
</div>
);
}
// ✗ Bad: Inconsistent layout (p-4 wrapper, no sticky headers, simple prev/next)
export default function EntityListPage() {
return (
<div className="p-4 space-y-3">
<h1 className="text-2xl font-bold">Entities</h1>
<div className="bg-card rounded-lg border shadow-sm">
<table>{/* non-sticky headers, different padding */}</table>
</div>
</div>
);
}Shared components: Use FilterChip from @/components/ui/filter-chip and ResizableHeader from @/components/ui/resizable-header where applicable.
- Advanced Filters (FilterBuilder): Powered by generic DSL (
?af=query param), supports dynamic field conditions with type-switched operators, entity multi-select, saved filter views. Wired into supplier-apartments, bookings, and other list pages.- FilterBuilder component (
@/components/ui/filter-builder/filter-builder.tsx): Button + collapsible panel, condition rows, field picker, operator dropdown, value input, entity select component - Hooks:
useAdvancedFilters(moduleName)— encode/decode af param to/from URL, manages filter stateuseFilterFields(moduleName)— fetch available fields + operators fromGET /{module}/filter-fieldsendpointuseFilterViews(moduleName)— CRUD saved filter views (create, read, update, delete, share)
- FilterCondition shape:
{ field, op, value }wherevalueis string | number | boolean | string array - Hard limits: MAX 20 conditions, MAX 50 array values (enforced server-side, returns 400 if exceeded)
- Saved views: FilterView model (isShared, company-scoped); CRUD endpoints at
GET/POST /filter-views,PATCH /filter-views/:id,DELETE /filter-views/:id - Integration pattern: Add
<FilterBuilder module="supplier-apartments" />to list page; wrap form withuseAdvancedFilters('supplier-apartments')to sync URL params
- FilterBuilder component (
Pagination Footer Must Clear the Floating Chat Bubble (pr-20)
A fixed-position chat bubble occupies the bottom-right corner of every dashboard screen (feat(chat) 36473d1). Any fixed pagination footer with right-aligned controls MUST use pl-6 pr-20 (NOT px-6) so the page navigation buttons are not covered by the bubble.
// ✗ Bad: prev/next buttons hidden under the chat bubble
<footer className="flex shrink-0 items-center justify-between border-t border-border bg-card px-6 py-3">
// ✓ Good: 80px right padding clears the bubble
<footer className="flex shrink-0 items-center justify-between border-t border-border bg-card pl-6 pr-20 py-3">Reference implementations: suppliers/page.tsx, customers/page.tsx, properties/page.tsx, supplier-apartments/page.tsx, ota-properties/page.tsx. (Bookings is exempt — its pagination scrolls inside the body which already has paddingBottom: 80.) Checklist item for every NEW list screen with a fixed pagination footer.
Icon-Only <Button> Needs !p-0 (Not p-0)
Button size="sm" always injects px-3 py-1.5. In the generated Tailwind CSS, .p-0 sorts BEFORE .px-3/.py-1.5, so a plain p-0 on className loses the cascade and the size padding wins. On a fixed-width button (h-7 w-7) that leaves ~2px of content box and flex-shrinks the icon invisible.
// ✗ Bad: px-3/py-1.5 override p-0 → 28px button keeps 24px padding → icon squeezed invisible
<Button variant="outline" size="sm" className="h-7 w-7 p-0"><ChevronLeft className="h-3.5 w-3.5" /></Button>
// ✓ Good: !p-0 wins via !important; shrink-0 guards the icon against flex squeeze
<Button variant="outline" size="sm" className="h-7 w-7 !p-0"><ChevronLeft className="h-3.5 w-3.5 shrink-0" /></Button>Same rule applies to any utility that conflicts with a class the component itself sets (no tailwind-merge in this repo — later-in-stylesheet wins, not later-in-className).
List Pages MUST Use use-paginated-list Hook + TableLoadingOverlay
All new list/table pages must use the shared use-paginated-list hook (@/lib/use-paginated-list) instead of hand-rolled pagination. This hook manages SWR data fetching, URL param sync, loading state, and debounced filters/search. Pair it with <TableLoadingOverlay> component for the unified loading UX (dim table 60% + spinner, 150ms appear delay, rows stay visible via keepPreviousData).
Why: Kills the 2–3.5MB fetch-all anti-pattern. Shared stack ensures consistent pagination, sort, and loading behavior across all list pages.
Hook interface (actual contract — apps/web/lib/use-paginated-list.ts):
const {
rows, // TRow[] — current page rows (previous page kept during refetch)
total, // whole-dataset count from meta/pagination
totalPages, // raw (0 before first load) — render with Math.max(1, totalPages)
page, pageSize, // parsed from URL (?page=, ?pageSize=), clamped to ≥1
sort, // SortState | null — { key, dir } for SortableTh
isInitialLoading, // first load only → render the full placeholder
isPageChanging, // new key loading with previous rows shown → drive the overlay
apiKey, // current SWR key — pass to mutateApi(apiKey) after mutations
updateParams, // (updates: Record<string, string | null>) => void — URL writes;
// any non-`page` change auto-resets to page 1
setSort, // (key: string) => void — toggles asc/desc, resets page
} = usePaginatedList<TRow>({
endpoint: 'properties', // API path, no query string
filters: { search: debouncedSearch, country }, // page-owned, already debounced
defaultPageSize: 20,
swr: { refreshInterval: 30_000 }, // keepPreviousData is ALWAYS on (can't override)
});Usage pattern:
// ✓ Good: matches ota-properties/page.tsx (canonical example)
<div className="flex-1 overflow-auto bg-card">
{isInitialLoading ? (
<LoadingPlaceholder />
) : (
<TableLoadingOverlay loading={isPageChanging}>
{rows.length === 0 ? <EmptyState /> : (
<MyTable rows={rows} sort={sort} onSort={setSort} />
)}
</TableLoadingOverlay>
)}
</div>Reference implementations:
apps/web/app/(dashboard)/ota-properties/page.tsx— canonical exampleapps/web/components/properties/use-properties-query.ts— wrapping the hook with page-specific filters (tab→salesStatus, multi-country)apps/web/app/(dashboard)/suppliers/page.tsx,customers/page.tsxapps/web/app/(dashboard)/supplier-apartments/page.tsx— LIGHT integration only (page has its own unified URL-filter hook):keepPreviousDataon its fetches + overlay + URLsortBy/sortOrderread locally. Do the same when a page already owns its URL params.
Components:
<TableLoadingOverlay loading={boolean}>{children}</TableLoadingOverlay>—@/components/ui/table-loading-overlay; WRAPS the table; dims content 60% + sticky spinner; both gates wait 150ms so fast fetches never flicker. The 30s background revalidate never dims —isPageChanginguses SWRisLoading(key change), notisValidating.
Endpoint contracts: list endpoints return { data, meta: { page, limit, total, totalPages } } (ota-connections) or { data, pagination: {...} } (others) — the hook normalizes both. Server-side sort via sortBy (per-module @IsIn whitelist) + sortOrder (asc|desc); every findMany adds a stable { id: 'asc' } tiebreaker. Aggregates for KPI/chips come from GET /{entity}/stats (and GET /properties/facets) — ?country= narrows headline counts, byCountry stays scope-global. Do NOT assert sum(byCountry) === total (null-country rows are dropped from the map). Stats endpoints accept a single country only (no comma lists).
SWR Paginated Lists Must Use keepPreviousData
Changing page/filter swaps the SWR key → data becomes undefined and isLoading flips true → the whole screen (header counts, table) flashes empty/loading. For server-paginated lists:
// ✓ Good: previous page stays visible while the next page loads
const { data } = useApiGet(apiKey, { refreshInterval: 30_000, keepPreviousData: true });
// Full-screen loading placeholder ONLY on first load:
{!data ? <Loading /> : <Table rows={data.data} />}
// Page indicator reads the URL param (instant), not meta.page (lags one fetch):
<span>{page} / {meta.totalPages}</span>Note: The use-paginated-list hook automates this pattern — prefer it for new list pages.
Outside-Click Close Must Be Portal-Aware
Shared dropdowns (Select, confirm dialogs) render via createPortal(..., document.body), so their DOM is outside any popover container that embeds them. A naive ref.current.contains(e.target) check classifies clicks on portaled options as "outside" and closes the parent popover mid-interaction.
// ✗ Bad: closes the popover when a child Select's portaled option is clicked
const close = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', close);
// ✓ Good: React synthetic events bubble through the REACT tree (portals included).
// Flag the mousedown via capture on the popover root; the document listener
// fires after React's handlers, so the flag is current.
const insideMouseDownRef = useRef(false);
// on the popover root element:
// onMouseDownCapture={() => { insideMouseDownRef.current = true; }}
const close = () => {
if (!insideMouseDownRef.current) setOpen(false);
insideMouseDownRef.current = false;
};
document.addEventListener('mousedown', close);Reference implementation: apps/web/components/ui/filter-builder/filter-builder.tsx.
Detail Page Edit: Inline Edit Mode, Not Modal
Entity detail pages MUST edit via an inline edit mode that swaps the read-only
sections for the shared create/edit form in place. Do NOT put large entity forms in aModal: the default size is max-w-lg (512px), which crushes multi-column form grids
and forces scrolling inside a max-h-[90vh] box.
// ✓ Good: toggle view ↔ edit in place, reuse the shared form + defaults mapper
const [isEditing, setIsEditing] = useState(false);
{isEditing ? (
<EntityForm
entityId={entity.id} // real UUID from the fetched entity, never the route param
defaultValues={entityToFormDefaults(entity)}
onSuccess={() => { toast.success(t('entity.saved')); setIsEditing(false); mutateApi(key); }}
onCancel={() => setIsEditing(false)}
/>
) : (
<EntityDetailSections entity={entity} />
)}
// ✗ Bad: 22-field form inside <Modal> — 512px wide, double scrollbars
<Modal isOpen={showEditModal} title={...}><EntityForm ... /></Modal>Requirements:
Layout continuity (CRITICAL): edit mode MUST mirror the read-only layout — same
section cards (sharedSectionCardchrome), same grid (grid-cols-1 lg:grid-cols-2),
same card order and field order. Only the field VALUES change into inputs. Toggling
view ↔ edit must not reflow the page (no width change, no cards jumping) — a layout
jump reads as unpolished and disorients the user. Fields that live in the page header
in view mode (name, relations) go into one extra full-span "Basic" card at the top.
The shared form renders this card grid itself, so the create page gets the same layout
for free — do NOT wrap the form in a narrower container on either page.Hide the header Edit/Delete actions while editing — the form's Save/Cancel take over.
Scroll to top on entering edit mode. The dashboard
<main>is the scroll container
(notwindow), so use a ref on the page root:rootRef.current?.scrollIntoView({ block: 'start' }).Dirty-guard on Cancel: if the form has unsaved changes, show a
ConfirmDialog
(common.unsavedChangesTitle/common.unsavedChangesMessage/common.discard)
instead of discarding silently. Use RHFformState.isDirty; state held outside RHF
(e.g. amenities maps, photo textareas) is compared against initial-value refs.Dialogs rendered inside a
<form>must not submit it.Modaldoes NOT portal —
its DOM stays inside the form, so every dialog-internal<button>needs explicittype="button"(Modal's close X andConfirmDialog's buttons already set this).
Modals remain correct for: confirmations (ConfirmDialog), small forms (≤ ~5 fields,
single column), and pickers. Existing detail pages that still edit via modal should be
migrated to this pattern when next touched.
Reference implementation: apps/web/app/(dashboard)/ota-listings/[id]/page.tsx +apps/web/components/ota-listings/ota-listing-form.tsx.
2. Git Commit Standards
Conventional Commits
<type>(<scope>): <subject>
<body>
<footer>Types:
| Type | Use for |
|---|---|
feat | New feature |
fix | Bug fix |
docs | Documentation only |
style | Formatting, no logic change |
refactor | Code restructure, no feature change |
test | Add or update tests |
chore | Build, CI, dependency updates |
Examples:
# ✓ Good
git commit -m "feat(auth): add refresh token revocation on logout"
git commit -m "fix(properties): prevent cross-country property access"
git commit -m "docs: update API_SPEC with country scoping details"
git commit -m "refactor(sync-engine): extract availability calc into separate service"
# ✗ Bad
git commit -m "fix stuff"
git commit -m "Update code"
git commit -m "wip"Pre-Push Checklist
pnpm lint # No errors
pnpm test # All tests pass
pnpm build # No compile errorsNever commit:
.envfiles or API keysnode_modules/,dist/,.next/- Generated Prisma client files
3. Performance Guidelines
API Response Size
// ✓ Good: Select only fields needed for list view
const bookings = await this.prisma.booking.findMany({
select: {
id: true,
otaBookingId: true,
guestName: true,
checkIn: true,
checkOut: true,
status: true,
// Exclude: rawData, syncHistory (large fields for detail view only)
},
});
// ✗ Bad: Return all fields including large nested data
const bookings = await this.prisma.booking.findMany();
// Response includes rawData (full OTA response JSON), syncHistory arrays, etc.Always Paginate
// ✓ Good: Default 25, max 100
@Get()
async findAll(
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('limit', new DefaultValuePipe(25), ParseIntPipe) limit: number,
) {
// Cap at 100 to prevent abuse
const safeLimitimit = Math.min(limit, 100);
return this.service.findAll(page, safeLimit);
}Frontend: Avoid Unnecessary Re-renders
// ✓ Good: Stable callback references
const updateUser = useCallback((updated: Partial<User>) => {
setUser(prev => prev ? { ...prev, ...updated } : null);
}, []);
// ✓ Good: Memoize derived data
const overdueAlerts = useMemo(
() => alerts?.filter(a => !a.resolvedAt) ?? [],
[alerts],
);
// ✗ Bad: Inline functions recreated each render
<button onClick={() => updateUser({ locale: 'vi' })}>
Switch to Vietnamese
</button>
// ❌ New function reference every render causes child re-render4. Related Documentation
- code-standards.md — Principles, naming, file size rules
- code-standards-backend.md — NestJS, security, DB, testing
- system-architecture.md — Architecture diagrams
- UI_SPEC.md — UI design system and screen specs