# Templates Frontend - Application Skill

> These templates generate React/TypeScript code for new applications/modules.

---

## FRONTEND ARCHITECTURE

```
web/smartstack-web/src/
├── pages/$CONTEXT/$APPLICATION/$MODULE/
│   ├── $MODULE_PASCALPage.tsx          # Main page (list)
│   ├── $MODULE_PASCALDetailPage.tsx    # Detail page
│   └── Create$MODULE_PASCALPage.tsx    # Create page
├── components/$CONTEXT/$MODULE/
│   ├── $MODULE_PASCALListView.tsx      # Reusable list component
│   ├── $MODULE_PASCALForm.tsx          # CRUD form
│   └── $MODULE_PASCALFilters.tsx       # Filters
├── components/common/
│   └── (shared components)
├── hooks/
│   ├── use$MODULE_PASCALPreferences.ts # Preferences hook
│   └── use$MODULE_PASCAL.ts            # API hook
├── services/api/
│   └── $moduleApi.ts                   # API service
└── i18n/locales/
    ├── fr/$module.json
    ├── en/$module.json
    ├── it/$module.json
    └── de/$module.json
```

---

## TEMPLATE: MAIN PAGE

```tsx
// pages/$CONTEXT/$APPLICATION/$MODULE/$MODULE_PASCALPage.tsx

import { useTranslation } from 'react-i18next';
import { $MODULE_PASCALListView } from '@/components/$CONTEXT/$MODULE/$MODULE_PASCALListView';

export function $MODULE_PASCALPage() {
  const { t } = useTranslation(['$module', 'common']);

  return (
    <$MODULE_PASCALListView
      title={t('$module:title')}
      subtitle={t('$module:subtitle')}
      basePath="/$CONTEXT/$APPLICATION/$MODULE"
      createPath="/$CONTEXT/$APPLICATION/$MODULE/new"
    />
  );
}
```

---

## TEMPLATE: LIST VIEW (Reusable component)

```tsx
// components/$CONTEXT/$MODULE/$MODULE_PASCALListView.tsx

import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Plus, Search, MoreVertical, Pencil, Trash2, Check, AlertCircle } from 'lucide-react';
import { api } from '@/services/api/apiClient';
import { use$MODULE_PASCALPreferences } from '@/hooks/use$MODULE_PASCALPreferences';
import { Pagination } from '@/components/common/Pagination';
import { ColumnSelector } from '@/components/common/ColumnSelector';
import { ViewToggle } from '@/components/ui/DataView';
import { EntityCard } from '@/components/ui/EntityCard';  // ⚠️ MANDATORY for Grid view

interface $ENTITY_PASCALDto {
  id: string;
  name: string;
  description: string | null;
  isActive: boolean;
  createdAt: string;
  updatedAt: string | null;
}

interface PagedResult {
  items: $ENTITY_PASCALDto[];
  totalCount: number;
  page: number;
  pageSize: number;
}

interface $MODULE_PASCALListViewProps {
  title: string;
  subtitle?: string;
  basePath: string;
  createPath?: string;
}

const DEFAULT_COLUMNS = ['name', 'description', 'isActive', 'createdAt'];

export function $MODULE_PASCALListView({
  title,
  subtitle,
  basePath,
  createPath,
}: $MODULE_PASCALListViewProps) {
  const { t } = useTranslation(['$module', 'common']);
  const navigate = useNavigate();
  const {
    pageSize,
    sortColumn,
    sortDirection,
    visibleColumns,
    viewMode,
    setPageSize,
    setSortColumn,
    setSortDirection,
    setVisibleColumns,
    setViewMode,
  } = use$MODULE_PASCALPreferences();

  const [data, setData] = useState<PagedResult | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [page, setPage] = useState(1);
  const [searchTerm, setSearchTerm] = useState('');

  useEffect(() => {
    loadData();
  }, [page, pageSize, sortColumn, sortDirection, searchTerm]);

  const loadData = async () => {
    try {
      setLoading(true);
      setError(null);
      const params = new URLSearchParams({
        page: page.toString(),
        pageSize: pageSize.toString(),
        sortColumn,
        sortDirection,
        ...(searchTerm && { searchTerm }),
      });
      const result = await api.get<PagedResult>(`/api/$module?${params}`);
      setData(result);
    } catch (err) {
      setError(t('common:errors.loadFailed'));
      console.error('Failed to load $module:', err);
    } finally {
      setLoading(false);
    }
  };

  const handleSort = (column: string) => {
    if (sortColumn === column) {
      setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
    } else {
      setSortColumn(column);
      setSortDirection('asc');
    }
  };

  const handleDelete = async (id: string) => {
    if (!confirm(t('common:confirmDelete'))) return;

    try {
      await api.delete(`/api/$module/${id}`);
      loadData();
    } catch (err) {
      console.error('Failed to delete:', err);
    }
  };

  const columns = [
    { key: 'name', label: t('$module:columns.name'), sortable: true },
    { key: 'description', label: t('$module:columns.description'), sortable: false },
    { key: 'isActive', label: t('$module:columns.status'), sortable: true },
    { key: 'createdAt', label: t('$module:columns.createdAt'), sortable: true },
  ];

  return (
    <div className="space-y-6">
      {/* Header */}
      <div className="flex items-center justify-between">
        <div>
          <h1 className="text-2xl font-bold text-[var(--text-primary)]">{title}</h1>
          {subtitle && (
            <p className="text-[var(--text-secondary)] mt-1">{subtitle}</p>
          )}
        </div>
        {createPath && (
          <button
            onClick={() => navigate(createPath)}
            className="flex items-center gap-2 px-4 py-2 bg-[var(--color-accent-500)] hover:bg-[var(--color-accent-600)] text-white font-medium transition-colors"
            style={{ borderRadius: 'var(--radius-button)' }}
          >
            <Plus className="w-4 h-4" />
            {t('common:actions.create')}
          </button>
        )}
      </div>

      {/* Toolbar */}
      <div className="flex items-center justify-between gap-4">
        <div className="relative flex-1 max-w-md">
          <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-muted)]" />
          <input
            type="text"
            placeholder={t('common:search.placeholder')}
            value={searchTerm}
            onChange={(e) => setSearchTerm(e.target.value)}
            className="w-full pl-10 pr-4 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] text-sm focus:outline-none focus:border-[var(--color-accent-500)]"
            style={{ borderRadius: 'var(--radius-input)' }}
          />
        </div>
        <div className="flex items-center gap-2">
          <ColumnSelector
            columns={columns}
            visibleColumns={visibleColumns}
            onChange={setVisibleColumns}
          />
          <ViewToggle value={viewMode} onChange={setViewMode} />
        </div>
      </div>

      {/* Content */}
      {loading ? (
        <div className="flex items-center justify-center py-12">
          <div className="animate-spin h-8 w-8 border-4 border-[var(--color-accent-500)] border-t-transparent rounded-full" />
        </div>
      ) : error ? (
        <div className="p-4 bg-[var(--error-bg)] border border-[var(--error-border)] rounded-[var(--radius-card)] flex items-center gap-3">
          <AlertCircle className="w-5 h-5 text-[var(--error-dot)] flex-shrink-0" />
          <span className="text-[var(--error-text)]">{error}</span>
          <button onClick={() => fetchData()} className="ml-auto text-sm text-[var(--error-text)] hover:underline">
            {t('common:actions.retry')}
          </button>
        </div>
      ) : data?.items.length === 0 ? (
        <div className="text-center py-16">
          <div className="w-16 h-16 mx-auto rounded-full bg-[var(--bg-secondary)] flex items-center justify-center mb-4">
            <Search className="w-8 h-8 text-[var(--text-tertiary)]" />
          </div>
          <p className="text-[var(--text-secondary)]">{t('$module:list.empty')}</p>
        </div>
      ) : viewMode === 'list' ? (
        <div className="bg-[var(--bg-card)] border border-[var(--item-color-border)] overflow-hidden" style={{ borderRadius: 'var(--radius-card)' }}>
          <table className="w-full">
            <thead>
              <tr className="border-b border-[var(--item-color-border)]">
                {columns.filter(col => visibleColumns.includes(col.key)).map((col) => (
                  <th
                    key={col.key}
                    className={`px-4 py-3 text-left text-sm font-medium text-[var(--text-secondary)] ${
                      col.sortable ? 'cursor-pointer hover:text-[var(--text-primary)]' : ''
                    }`}
                    onClick={() => col.sortable && handleSort(col.key)}
                  >
                    {col.label}
                    {sortColumn === col.key && (
                      <span className="ml-1">{sortDirection === 'asc' ? '↑' : '↓'}</span>
                    )}
                  </th>
                ))}
                <th className="px-4 py-3 text-right text-sm font-medium text-[var(--text-secondary)]">
                  {t('common:actions.title')}
                </th>
              </tr>
            </thead>
            <tbody>
              {data?.items.map((item) => (
                <tr
                  key={item.id}
                  className="border-b border-[var(--item-color-border)] hover:bg-[var(--bg-hover)] cursor-pointer"
                  onClick={() => navigate(`${basePath}/${item.id}`)}
                >
                  {visibleColumns.includes('name') && (
                    <td className="px-4 py-3 text-sm text-[var(--text-primary)] font-medium">
                      {item.name}
                    </td>
                  )}
                  {visibleColumns.includes('description') && (
                    <td className="px-4 py-3 text-sm text-[var(--text-secondary)]">
                      {item.description || '-'}
                    </td>
                  )}
                  {visibleColumns.includes('isActive') && (
                    <td className="px-4 py-3">
                      <span
                        className={`inline-flex items-center px-2 py-1 text-xs font-medium ${
                          item.isActive
                            ? 'bg-[var(--success-bg)] text-[var(--success-text)]'
                            : 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
                        }`}
                        style={{ borderRadius: 'var(--radius-badge)' }}
                      >
                        {item.isActive ? t('common:status.active') : t('common:status.inactive')}
                      </span>
                    </td>
                  )}
                  {visibleColumns.includes('createdAt') && (
                    <td className="px-4 py-3 text-sm text-[var(--text-secondary)]">
                      {new Date(item.createdAt).toLocaleDateString()}
                    </td>
                  )}
                  <td className="px-4 py-3 text-right">
                    <div className="flex items-center justify-end gap-2">
                      <button
                        onClick={(e) => {
                          e.stopPropagation();
                          navigate(`${basePath}/${item.id}/edit`);
                        }}
                        className="p-1 hover:bg-[var(--bg-tertiary)]"
                        style={{ borderRadius: 'var(--radius-button)' }}
                      >
                        <Pencil className="w-4 h-4 text-[var(--text-secondary)]" />
                      </button>
                      <button
                        onClick={(e) => {
                          e.stopPropagation();
                          handleDelete(item.id);
                        }}
                        className="p-1 hover:bg-[var(--error-bg)]"
                        style={{ borderRadius: 'var(--radius-button)' }}
                      >
                        <Trash2 className="w-4 h-4 text-[var(--error-text)]" />
                      </button>
                    </div>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      ) : (
        /* Grid view - ⚠️ MANDATORY: use EntityCard */
        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
          {data?.items.map((item) => (
            <EntityCard
              key={item.id}
              avatar={{ letter: item.name[0].toUpperCase(), color: 'var(--color-accent-500)' }}
              title={item.name}
              subtitle={item.code}
              description={item.description}
              badge={item.isActive ? {
                icon: Check,
                tooltip: t('common:status.active'),
                color: 'var(--success-text)'
              } : undefined}
              stats={`${t('common:createdAt')}: ${new Date(item.createdAt).toLocaleDateString()}`}
              onClick={() => navigate(`${basePath}/${item.id}`)}
              actions={[
                { label: t('common:actions.view'), onClick: () => navigate(`${basePath}/${item.id}`), variant: 'primary' },
              ]}
            />
          ))}
        </div>
      )}

      {/* Pagination */}
      {data && (
        <Pagination
          page={data.page}
          pageSize={data.pageSize}
          totalCount={data.totalCount}
          onPageChange={setPage}
          onPageSizeChange={setPageSize}
        />
      )}
    </div>
  );
}
```

---

## TEMPLATE: PREFERENCES HOOK

```tsx
// hooks/use$MODULE_PASCALPreferences.ts

import { useUserPreferences } from '@/contexts/UserPreferencesContext';

const DEFAULT_COLUMNS = ['name', 'description', 'isActive', 'createdAt'];

export function use$MODULE_PASCALPreferences() {
  const { preferences, updatePreference } = useUserPreferences();
  const modulePrefs = preferences.$module || {};

  return {
    // Getters with defaults
    pageSize: modulePrefs.pageSize ?? 10,
    sortColumn: modulePrefs.sortColumn ?? 'createdAt',
    sortDirection: modulePrefs.sortDirection ?? 'desc',
    filters: modulePrefs.filters ?? {},
    visibleColumns: modulePrefs.visibleColumns ?? DEFAULT_COLUMNS,
    viewMode: modulePrefs.viewMode ?? 'list',

    // Setters
    setPageSize: (size: number) =>
      updatePreference('$module.pageSize', size),
    setSortColumn: (col: string) =>
      updatePreference('$module.sortColumn', col),
    setSortDirection: (dir: 'asc' | 'desc') =>
      updatePreference('$module.sortDirection', dir),
    setFilters: (filters: Record<string, unknown>) =>
      updatePreference('$module.filters', filters),
    setVisibleColumns: (cols: string[]) =>
      updatePreference('$module.visibleColumns', cols),
    setViewMode: (mode: 'list' | 'grid') =>
      updatePreference('$module.viewMode', mode),
  };
}
```

---

## TEMPLATE: SERVICE API

```tsx
// services/api/$moduleApi.ts

import { api } from './apiClient';

export interface $ENTITY_PASCALDto {
  id: string;
  name: string;
  description: string | null;
  isActive: boolean;
  createdAt: string;
  updatedAt: string | null;
}

export interface Create$ENTITY_PASCALRequest {
  name: string;
  description?: string;
}

export interface Update$ENTITY_PASCALRequest {
  name: string;
  description?: string;
}

export interface PagedResult<T> {
  items: T[];
  totalCount: number;
  page: number;
  pageSize: number;
}

export interface QueryParameters {
  page?: number;
  pageSize?: number;
  searchTerm?: string;
  sortColumn?: string;
  sortDirection?: 'asc' | 'desc';
}

export const $moduleApi = {
  getAll: async (params: QueryParameters = {}): Promise<PagedResult<$ENTITY_PASCALDto>> => {
    const queryParams = new URLSearchParams();
    if (params.page) queryParams.set('page', params.page.toString());
    if (params.pageSize) queryParams.set('pageSize', params.pageSize.toString());
    if (params.searchTerm) queryParams.set('searchTerm', params.searchTerm);
    if (params.sortColumn) queryParams.set('sortColumn', params.sortColumn);
    if (params.sortDirection) queryParams.set('sortDirection', params.sortDirection);

    return api.get(`/api/$module?${queryParams}`);
  },

  getById: async (id: string): Promise<$ENTITY_PASCALDto> => {
    return api.get(`/api/$module/${id}`);
  },

  create: async (data: Create$ENTITY_PASCALRequest): Promise<$ENTITY_PASCALDto> => {
    return api.post('/api/$module', data);
  },

  update: async (id: string, data: Update$ENTITY_PASCALRequest): Promise<$ENTITY_PASCALDto> => {
    return api.put(`/api/$module/${id}`, data);
  },

  delete: async (id: string): Promise<void> => {
    return api.delete(`/api/$module/${id}`);
  },
};
```

---

## TEMPLATE: ROUTES (App.tsx)

### ⚠️ CRITICAL RULE 1: ROUTES MUST BE INSIDE LAYOUT WRAPPER

SmartStack layouts (`AdminLayout`, `BusinessLayout`, `UserLayout`) provide the application shell: **header with AvatarMenu**, sidebar, navigation. They render child pages via React Router's `<Outlet />`.

**If routes are placed OUTSIDE the layout wrapper, the shell (header, sidebar, AvatarMenu) will NOT render. The page appears "naked" without any navigation.**

**Step-by-step insertion:**

1. Open `App.tsx`
2. Find the existing layout route for the target context:
   - `platform` → `<Route path="/platform" element={<AdminLayout />}>`
   - `business` → `<Route path="/business" element={<BusinessLayout />}>`
   - `personal` → `<Route path="/personal/myspace" element={<UserLayout />}>`
3. Add the new routes **INSIDE** that `<Route>` block
4. If a tenant-prefixed block exists (`/t/:slug/...`), add the routes there too

**❌ FORBIDDEN - Flat routes OUTSIDE layout (AvatarMenu disappears!)**
```tsx
// These routes bypass the layout system entirely = NO header, NO sidebar, NO AvatarMenu
<Route path="/$CONTEXT/$APPLICATION/$MODULE" element={<$MODULE_PASCALPage />} />
```

**✅ MANDATORY - Routes INSIDE the existing layout wrapper**
```tsx
// Add to App.tsx

import { $MODULE_PASCALPage } from '@/pages/$CONTEXT/$APPLICATION/$MODULE/$MODULE_PASCALPage';
import { $MODULE_PASCALDetailPage } from '@/pages/$CONTEXT/$APPLICATION/$MODULE/$MODULE_PASCALDetailPage';
import { Create$MODULE_PASCALPage } from '@/pages/$CONTEXT/$APPLICATION/$MODULE/Create$MODULE_PASCALPage';

// Find the EXISTING layout route and add routes INSIDE it:
<Route path="/$CONTEXT" element={<$CONTEXT_Layout />}>
  {/* ... existing routes stay here ... */}

  {/* NEW: $APPLICATION routes - added as children of the layout */}
  <Route path="$APPLICATION">
    <Route index element={<Navigate to="$DEFAULT_MODULE" replace />} />
    <Route path="$MODULE" element={<$MODULE_PASCALPage />} />
    <Route path="$MODULE/new" element={<Create$MODULE_PASCALPage />} />
    <Route path="$MODULE/:id" element={<$MODULE_PASCALDetailPage />} />
    <Route path="$MODULE/:id/edit" element={<Create$MODULE_PASCALPage />} />
  </Route>
</Route>
```

### Context-to-Layout mapping

| Context | Layout Component | Layout Route Path |
|---------|------------------|-------------------|
| `platform` | `AdminLayout` | `/platform` |
| `business` | `BusinessLayout` | `/business` |
| `personal` | `UserLayout` | `/personal/myspace` |

### ⚠️ CRITICAL RULE 2: NESTED ROUTES MANDATORY

React Router v7 **requires** nested routes for multi-module applications.

**❌ FORBIDDEN - Flat routes (cause redirects to Home)**
```tsx
<Route path="$APPLICATION" element={<Navigate to="/$CONTEXT/$APPLICATION/$DEFAULT_MODULE" replace />} />
<Route path="$APPLICATION/$MODULE" element={<$MODULE_PASCALPage />} />
```

**✅ MANDATORY - Nested routes with index**
```tsx
<Route path="$APPLICATION">
  <Route index element={<Navigate to="$DEFAULT_MODULE" replace />} />
  <Route path="$MODULE" element={<$MODULE_PASCALPage />} />
</Route>
```

### Why this structure?

| Aspect | Flat routes | Nested routes |
|--------|------------|----------------|
| Shell rendered | ❌ No (bypasses layout) | ✅ Yes (Outlet pattern) |
| AvatarMenu visible | ❌ No | ✅ Yes |
| Matching | Ambiguous between siblings | Hierarchical clear |
| Navigate | Must be absolute | Can be relative |
| Outlet | Not supported | Supported |
| Redirect | Can fail | Always works |

---

## ⛔ FORBIDDEN PATTERNS

These patterns are **strictly prohibited** in generated frontend code:

| FORBIDDEN | REQUIRED |
|-----------|----------|
| `import axios from 'axios'` | `import { api } from '@/services/api/apiClient'` |
| `bg-blue-600`, `text-gray-900`, `bg-green-100` | `bg-[var(--color-accent-600)]`, `text-[var(--text-primary)]`, `bg-[var(--success-bg)]` |
| Any hardcoded Tailwind color (`bg-{color}-{shade}`) | CSS variables from style-guide.md |
| `rounded-lg`, `rounded-md` | `rounded-[var(--radius-card)]`, `rounded-[var(--radius-button)]` |
| Custom `<div>` cards for entity lists | `<EntityCard>` component (MANDATORY) |
| Flat routes outside Layout wrapper | Nested routes inside `<BusinessLayout>` / `<AdminLayout>` |
| Only 2 languages (fr/en) | All 4 languages (fr/en/it/de) |
| `[Authorize]` without permissions | `[RequirePermission("navRoute.action")]` per endpoint |
| Missing error state | Error state with retry button (AlertCircle + underline) |
| Missing empty state | Empty state with icon + message |
| `new Date().toLocaleDateString()` without locale | Date formatting via i18n or Intl |

---

## FRONTEND CHECKLIST

### Structure
| Check | Status |
|-------|--------|
| ☐ Main page created (`$MODULE_PASCALPage.tsx`) | |
| ☐ ListView component created (`$MODULE_PASCALListView.tsx`) | |
| ☐ Preferences hook created (`use$MODULE_PASCALPreferences.ts`) | |
| ☐ API service created (uses `apiClient`, NOT raw axios) | |
| ☐ Routes added inside Layout wrapper in App.tsx | |
| ☐ Route path follows `/{context}/{application}/{module}` | |

### Theme Compliance
| Check | Status |
|-------|--------|
| ☐ Zero hardcoded Tailwind colors (grep: `bg-blue-`, `text-gray-`, etc.) | |
| ☐ All colors use CSS variables (`var(--*)`) | |
| ☐ Border radius uses `var(--radius-*)` | |
| ☐ Buttons follow style-guide.md variants | |

### UI Components
| Check | Status |
|-------|--------|
| ☐ Grid view uses `EntityCard` (not custom divs) | |
| ☐ `ViewToggle` for list/grid switch | |
| ☐ Loading state (spinner) | |
| ☐ Error state (AlertCircle + retry button) | |
| ☐ Empty state (icon + message) | |
| ☐ Pagination component | |

### i18n
| Check | Status |
|-------|--------|
| ☐ French (fr) file created | |
| ☐ English (en) file created | |
| ☐ Italian (it) file created | |
| ☐ German (de) file created | |
| ☐ All 4 files have identical keys | |

### Build
| Check | Status |
|-------|--------|
| ☐ `npm run build` successful | |
| ☐ `npm run lint` successful | |
