Skip to content

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

typescript
// ✓ 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

typescript
// ✓ 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

typescript
// ✓ 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

typescript
// ✓ 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

typescript
// ✓ 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.ts typed builders — never hardcoded URL strings or raw UUIDs.
  • Builders enforce type safety: pass entity objects with .internalCode (or .supplierCode for suppliers, .code for departments).
  • Display .internalCode in UI, never the entity's UUID.
  • Old UUID URLs keep working (route params accept code OR UUID); new canonical URLs use internal codes.

Usage:

typescript
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 interpolation

Canonical redirect pattern (detail pages load via route param that may be UUID):

tsx
// 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 state
      • useFilterFields(moduleName) — fetch available fields + operators from GET /{module}/filter-fields endpoint
      • useFilterViews(moduleName) — CRUD saved filter views (create, read, update, delete, share)
    • FilterCondition shape: { field, op, value } where value is 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 with useAdvancedFilters('supplier-apartments') to sync URL params

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.

tsx
// ✗ 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.

tsx
// ✗ 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):

typescript
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:

tsx
// ✓ 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 example
  • apps/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.tsx
  • apps/web/app/(dashboard)/supplier-apartments/page.tsx — LIGHT integration only (page has its own unified URL-filter hook): keepPreviousData on its fetches + overlay + URL sortBy/sortOrder read 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 — isPageChanging uses SWR isLoading (key change), not isValidating.

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:

tsx
// ✓ 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.

typescript
// ✗ 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 a
Modal: the default size is max-w-lg (512px), which crushes multi-column form grids
and forces scrolling inside a max-h-[90vh] box.

tsx
// ✓ 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 (shared SectionCard chrome), 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
    (not window), 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 RHF formState.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. Modal does NOT portal —
    its DOM stays inside the form, so every dialog-internal <button> needs explicit
    type="button" (Modal's close X and ConfirmDialog'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:

TypeUse for
featNew feature
fixBug fix
docsDocumentation only
styleFormatting, no logic change
refactorCode restructure, no feature change
testAdd or update tests
choreBuild, CI, dependency updates

Examples:

bash
# ✓ 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

bash
pnpm lint        # No errors
pnpm test        # All tests pass
pnpm build       # No compile errors

Never commit:

  • .env files or API keys
  • node_modules/, dist/, .next/
  • Generated Prisma client files

3. Performance Guidelines

API Response Size

typescript
// ✓ 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

typescript
// ✓ 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

typescript
// ✓ 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-render

PTX Channel Manager — Internal Documentation