Skip to content

Frontend Architecture (Wilo.App)

Management SPA for Club Wilo employees and administrators. Built with Vue 3 Composition API, Pinia, Vue Router, and Axios. Served as a static SPA behind Nginx in production.


Core Principles

PrincipleImplementation
Composition API<script setup lang="ts"> in every SFC — Options API is prohibited
Single File ComponentsScript → Template → Style scoped order in every .vue file
Setup Function StoresdefineStore('name', () => {...}) — no options-style stores
Feature-Based OrganizationComponents, views, stores, and API modules grouped by business domain
Type SafetyStrict TypeScript, Zod validation, typed API responses
i18n from Day 1Every user-visible string uses t('key') — no hardcoded text

Project Structure

sites/Wilo.App/
├── assets/                     — CSS (Tailwind), fonts, images
├── components/
│   ├── ui/                     — shadcn-vue primitives (do not edit directly)
│   ├── common/                 — Shared components (DataTable, ConfirmDialog, ErrorAlert)
│   ├── layout/                 — AppSidebar, AppHeader, ModuleSwitcher, NavUser
│   ├── admin/                  — Platform module domain components
│   ├── auth/                   — Login, register, password forms
│   ├── club/                   — Club Wilo domain components (members, plans, promotions)
│   ├── profile/                — User profile components
│   ├── setup/                  — Setup wizard components
│   ├── AppLayout.vue           — Root layout dispatcher (blank vs management)
│   └── LocaleSwitcher.vue      — Language toggle
├── composables/                — Reusable Composition API hooks
│   ├── useFormValidation.ts    — Form submission with error handling
│   ├── useBreadcrumb.ts        — Dynamic breadcrumb generation
│   └── usePagination.ts        — Server-side pagination state
├── config/
│   └── modules.ts              — Module navigation definitions (sidebar items)
├── lib/                        — API communication layer
│   ├── api.ts                  — Axios instance + interceptors + token management
│   ├── auth-api.ts             — Authentication endpoints
│   ├── members-api.ts          — Members CRUD
│   ├── plans-api.ts            — Subscription plans
│   ├── subscriptions-api.ts    — Enrollment and subscription management
│   ├── promotions-api.ts       — Promotions and promo codes
│   ├── referrals-api.ts        — Referral program
│   ├── admin-api.ts            — User and role management
│   ├── error-utils.ts          — Error extraction and classification
│   ├── toast-notifications.ts  — Toast helpers (network error, session expired)
│   └── utils.ts                — General utilities
├── locales/
│   ├── en.json                 — English translations
│   └── es.json                 — Spanish translations
├── router/
│   ├── index.ts                — Route definitions (35+ routes)
│   ├── guards.ts               — Navigation guards (4 global guards)
│   └── types.ts                — RouteMeta augmentation
├── stores/                     — Pinia stores (setup function style)
│   ├── auth.ts                 — Authentication and authorization state
│   ├── members.ts              — Members list and detail
│   ├── plans.ts                — Subscription plans
│   ├── promotions.ts           — Promotions
│   ├── referrals.ts            — Referral program
│   ├── sidebar.ts              — Active module navigation
│   └── counter.ts              — Reference example
├── types/                      — TypeScript interfaces
│   ├── api.ts                  — Generic types (ApiError, PaginatedResult)
│   ├── auth.ts                 — User, LoginRequest, tokens
│   ├── admin.ts                — AdminUser, Role, Permission
│   ├── members.ts              — Member, Subscription, Plan (80+ interfaces)
│   └── sidebar.ts              — ModuleDefinition, NavItem
├── views/                      — Page components (one per route)
│   ├── admin/                  — Platform module pages
│   ├── auth/                   — Login, verify email, reset password
│   ├── club/                   — Members, plans, promotions, referrals
│   ├── profile/                — User profile and change password
│   └── setup/                  — Setup wizard
├── e2e/                        — Playwright E2E tests
├── test/                       — Vitest unit tests
├── i18n.ts                     — vue-i18n configuration
├── main.ts                     — Application entry point
├── App.vue                     — Root component
├── vite.config.ts              — Vite + Tailwind v4 + Vue DevTools
├── vitest.config.ts            — Vitest (jsdom environment)
└── playwright.config.ts        — Playwright (multi-browser + auth state)

Layer Architecture

┌─────────────────────────────────────────────────────────┐
│                     Vue Router                           │
│         (route definitions + meta fields)                │
└────────────────────────┬────────────────────────────────┘


┌─────────────────────────────────────────────────────────┐
│                  Navigation Guards                        │
│  setupGuard → authGuard → permissionGuard → forceChange  │
└────────────────────────┬────────────────────────────────┘


┌─────────────────────────────────────────────────────────┐
│                  Layouts + Views                          │
│  AppLayout dispatches → blank | management layout        │
│  Views are page components (one per route)               │
└────────────────────────┬────────────────────────────────┘

              ┌──────────┼──────────┐
              ▼          ▼          ▼
┌────────────────┐ ┌──────────┐ ┌────────────────┐
│   Components   │ │  Stores  │ │  Composables   │
│  (ui + domain) │ │ (Pinia)  │ │ (shared logic) │
└────────────────┘ └────┬─────┘ └────────────────┘


┌─────────────────────────────────────────────────────────┐
│                    API Layer (lib/)                       │
│  Axios instance + interceptors + feature API modules     │
└────────────────────────┬────────────────────────────────┘


                  ┌──────────────┐
                  │  Wilo.Api    │
                  │  (.NET API)  │
                  └──────────────┘

Data flows downward. Views call stores, stores call API modules, API modules call the Axios instance. Components receive data via props or store access. Navigation guards run before any view renders.


Routing and Layouts

Route Meta

Every route can declare metadata that navigation guards inspect before rendering the view:

typescript
interface RouteMeta {
    requiresAuth?: boolean        // Requires authentication (default: false)
    permissions?: string[]        // Required permissions (e.g., ['members:read'])
    layout?: 'blank' | 'management' // Which layout to render (default: blank)
}

Layouts

AppLayout.vue reads route.meta.layout and renders one of two layouts:

LayoutUsed forStructure
blankLogin, setup, verify email, reset passwordCentered card, no sidebar
managementAll authenticated pagesSidebarProvider + AppSidebar + AppHeader + main slot

Route Groups

Public routes (blank layout, no auth required):

PathViewPurpose
/loginLoginViewEmail/password authentication
/verify-emailVerifyEmailViewEmail verification callback
/forgot-passwordForgotPasswordViewRequest password reset
/reset-passwordResetPasswordViewSet new password via token
/setupSetupViewFirst-time platform setup wizard

Platform module (management layout, auth required):

PathViewPermissions
/platformHomeView
/platform/usersUsersViewusers:read, roles:read
/platform/rolesRolesViewroles:read, permissions:read
/platform/password-policyPasswordPolicyViewsettings:write

Club Wilo module (management layout, auth required):

PathViewPermissions
/clubClubDashboardViewmembers:read
/club/membersMembersViewmembers:read
/club/members/newMemberCreateViewmembers:write
/club/members/:idMemberDetailViewmembers:read
/club/members/:id/subscriptionMemberSubscriptionViewsubscriptions:read
/club/plansPlansViewplans:read
/club/plans/newPlanCreateViewplans:write
/club/promotionsPromotionsViewpromotions:read
/club/promotions/newPromotionCreateViewpromotions:write
/club/referralsReferralsViewreferrals:read
/club/referrals/leaderboardReferralLeaderboardViewreferrals:read

Guards run sequentially on every navigation. Defined in router/guards.ts and registered in router/index.ts:

setupGuard → authGuard → permissionGuard → forceChangePasswordGuard
GuardResponsibilityRedirect
setupGuardChecks if platform setup is complete/setup if incomplete
authGuardRedirects unauthenticated users; triggers silent refresh on first navigation/login if unauthenticated
permissionGuardChecks route.meta.permissions against user's permission set/unauthorized if denied
forceChangePasswordGuardRedirects if mustChangePassword flag is set/force-change-password

Silent refresh flow: On first protected navigation, authGuard calls authStore.refreshSession(). If the HttpOnly refresh cookie is valid, the user is silently authenticated without seeing the login page.


State Management (Pinia)

All stores use the setup function pattern. State is ref(), getters are computed(), actions are plain functions.

Auth Store

The central authentication and authorization store. All guards and permission checks depend on it.

typescript
// stores/auth.ts
export const useAuthStore = defineStore('auth', () => {
    // State
    const user = ref<User | null>(null)
    const permissions = ref<string[]>([])
    const mustChangePassword = ref(false)
    const isInitialized = ref(false)
    const setupStatus = ref<'unknown' | 'incomplete' | 'complete'>('unknown')

    // Getters
    const isAuthenticated = computed(() => user.value !== null)
    const isSetupComplete = computed(() => setupStatus.value === 'complete')

    // Actions
    async function login(request: LoginRequest) { ... }
    async function logout() { ... }
    async function refreshSession(): Promise<boolean> { ... }
    async function fetchProfile() { ... }
    function hasPermission(permission: string): boolean { ... }
    function hasAnyPermission(...perms: string[]): boolean { ... }
    async function checkSetupStatus(): Promise<void> { ... }
    function clearSession() { ... }

    return { user, permissions, mustChangePassword, isInitialized, setupStatus,
             isAuthenticated, isSetupComplete,
             login, logout, refreshSession, fetchProfile,
             hasPermission, hasAnyPermission, checkSetupStatus, clearSession }
})

Permission check: hasPermission() returns true if the user's permissions array includes the requested permission or the wildcard '*' (SuperAdmin).

Domain Stores

Each business domain has its own store managing list state, detail state, and loading flags:

StoreStateKey Actions
useMembersStoremembers[], selectedMember, stats, paginationfetchMembers, fetchMember, changeStatus, deleteMember
usePlansStoreplans[], selectedPlanfetchPlans, createPlan, updatePlan, deactivatePlan
usePromotionsStorepromotions[], selectedPromotionfetchPromotions, createPromotion, validateCode
useReferralsStoreleaderboard[], stats, overviewfetchLeaderboard, fetchStats, generateCode
useSidebarStoreactiveModule (persisted to localStorage)setActiveModule

API Communication Layer

Axios Instance

typescript
// lib/api.ts
const api = axios.create({
    baseURL: import.meta.env.VITE_API_URL || '/api',
    withCredentials: true,     // Include cookies in cross-origin requests
    headers: { 'Content-Type': 'application/json' },
})

Token Management

The access token lives in memory only — never in localStorage or sessionStorage. This prevents XSS from exfiltrating the token.

typescript
let accessToken: string | null = null

export function getAccessToken(): string | null { return accessToken }
export function setAccessToken(token: string | null) { accessToken = token }
export function clearAccessToken() { accessToken = null }

The refresh token is stored in an HttpOnly cookie set by the server. The browser sends it automatically with every request to the API (via withCredentials: true).

Request Interceptor

Attaches the Bearer token to every outgoing request:

typescript
api.interceptors.request.use((config) => {
    const token = getAccessToken()
    if (token) {
        config.headers.Authorization = `Bearer ${token}`
    }
    return config
})

Response Interceptor (401 Auto-Refresh)

Handles token expiration transparently. When a 401 response arrives:

Request fails with 401

    ├─ Is auth endpoint? → Reject (avoid infinite loop)
    ├─ Already retried? → Reject
    ├─ First 401?
    │   ├─ Lock: isRefreshing = true
    │   ├─ Call POST /auth/refresh (cookie sent automatically)
    │   ├─ On success:
    │   │   ├─ Update in-memory token
    │   │   ├─ Notify all queued requests
    │   │   └─ Retry original request with new token
    │   └─ On failure:
    │       ├─ Clear session
    │       └─ Redirect to /login
    └─ Concurrent 401? → Queue in refreshSubscribers, wait for first refresh

Thundering herd prevention: When multiple requests fail with 401 simultaneously, only the first triggers a refresh. All others queue in refreshSubscribers and are retried once the new token is available.

Feature API Modules

Each business domain has its own API module in lib/. Modules import the shared Axios instance and export typed functions:

typescript
// lib/members-api.ts
import api from './api'
import type { Member, MemberListItem, MemberListParams } from '@/types/members'
import type { PaginatedResult } from '@/types/api'

export const membersApi = {
    listMembers: (params?: MemberListParams) =>
        api.get<PaginatedResult<MemberListItem>>('/members', { params }),

    getMember: (id: string) =>
        api.get<Member>(`/members/${id}`),

    createMember: (data: CreateMemberRequest) =>
        api.post<Member>('/members', data),
    // ...
}
ModuleFileEndpoints
Authenticationauth-api.tslogin, register, refresh, logout, verify email, forgot/reset password, profile, setup
Membersmembers-api.tslist, get, create, update, delete, change status, stats, export
Plansplans-api.tslist, get, create, update, deactivate
Subscriptionssubscriptions-api.tsenroll, subscribe, get subscription, cancel, renew, change plan
Promotionspromotions-api.tslist, get, create, update, deactivate, validate code, usage
Referralsreferrals-api.tsgenerate code, validate, apply, leaderboard, stats, overview
Adminadmin-api.tsusers CRUD, roles CRUD, permissions list, assign role, blacklist

Authentication Flow

Login

1. User enters email + password on /login
2. authStore.login() calls POST /auth/login
3. Server returns { accessToken, email, role, mustChangePassword }
4. Store calls setAccessToken(accessToken)  ← in-memory only
5. Store calls fetchProfile() → GET /auth/me → populates user + permissions
6. Router redirects to /force-change-password (if flag set) or /platform

Silent Refresh (Page Load)

1. Browser loads app (or user refreshes page)
2. First protected route triggers authGuard
3. Guard calls authStore.refreshSession() (runs only once via isInitialized flag)
4. Store calls POST /auth/refresh (HttpOnly cookie sent automatically)
5. If successful: token updated, profile fetched → navigation proceeds
6. If failed: redirect to /login

Automatic Retry (401 Interceptor)

1. API call returns 401 (token expired mid-session)
2. Response interceptor locks isRefreshing = true
3. Calls POST /auth/refresh
4. Updates token, broadcasts to queued requests
5. Retries original failed request with new token
6. Concurrent 401s queue and wait (no duplicate refreshes)

Token Storage Comparison

Web (Wilo.App)Mobile (Wilo.Mobile)
Access tokenMemory (ref in store)Memory (ref in store)
Refresh tokenHttpOnly cookie (set by server)Capacitor Preferences (encrypted)
Refresh callCookie sent automaticallyToken sent in request body

See KB-007 for the full mobile token storage rationale.

Logout

1. authStore.logout() calls POST /auth/logout
2. Server invalidates refresh token cookie
3. Store calls clearSession() → clears token, user, permissions
4. Router redirects to /login

Component Organization

Component Directories

DirectoryPurposeEditing Policy
components/ui/shadcn-vue primitives (26 subdirectories)Do not edit directly; add new via bunx --bun shadcn-vue@latest add <component>
components/common/Shared components used across modules (DataTable, ConfirmDialog, ErrorAlert)Edit freely
components/layout/App shell (sidebar, header, module switcher, user menu)Edit freely
components/{domain}/Domain-specific components (admin/, auth/, club/, profile/, setup/)Edit freely

SFC Format

Every component follows the same structure:

vue
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'

const { t } = useI18n()
// ...
</script>

<template>
  <!-- Template content -->
</template>

<style scoped>
/* Only if needed */
</style>

Icons

Icons use lucide-vue-next — import named icon components directly:

vue
<script setup lang="ts">
import { Users } from 'lucide-vue-next'
</script>

<template>
  <Users :size="20" />
</template>

Lucide icons are Vue components and can be used directly in templates. The TypeScript type is LucideIcon from 'lucide-vue-next'.

Module Navigation Config

config/modules.ts defines the sidebar structure. Each module declares its navigation items, default route, and required permissions:

typescript
export const modules: ModuleDefinition[] = [
    {
        id: 'platform',
        labelKey: 'sidebar.modules.platform',
        icon: DashboardSquare01Icon,
        defaultRoute: '/platform',
        permissions: [],
        navItems: [
            { labelKey: 'nav.overview', icon: DashboardSquare01Icon, to: '/platform' },
            { labelKey: 'nav.users', icon: UserGroupIcon, to: '/platform/users',
              permission: 'users:read' },
            // ...
        ],
    },
    {
        id: 'club-wilo',
        labelKey: 'sidebar.modules.clubWilo',
        icon: Building01Icon,
        defaultRoute: '/club',
        permissions: ['members:read', 'plans:read', 'promotions:read', 'referrals:read'],
        navItems: [ /* ... */ ],
    },
]

The sidebar renders only the nav items the current user has permission to see.


Form Validation Pattern

Forms use VeeValidate for Vue integration and Zod for schema validation. The useFormValidation() composable provides loading state and error extraction.

Zod Schema + VeeValidate

typescript
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'
import { useI18n } from 'vue-i18n'

const { t } = useI18n()

const schema = toTypedSchema(
    z.object({
        email: z.string().email(t('validation.invalidEmail')),
        firstName: z.string().min(1, t('validation.required')),
        lastName: z.string().min(1, t('validation.required')),
    })
)

const { handleSubmit, errors, values } = useForm({ validationSchema: schema })

useFormValidation Composable

Wraps form submission with loading state and structured error handling:

typescript
const { isLoading, error, apiError, handleSubmit, clearError } = useFormValidation()

async function onSubmit() {
    await handleSubmit(async () => {
        await membersApi.createMember(formData)
        toast.success(t('members.created'))
        router.push('/club/members')
    })
}

On error, the composable extracts the API error (code + message) or detects network failures. The error ref contains a user-friendly message; apiError contains the structured { code, message } object.


Internationalization (i18n)

Configuration

typescript
// i18n.ts
const i18n = createI18n({
    legacy: false,           // Composition API mode
    locale: 'es',            // Default: Spanish
    fallbackLocale: 'en',   // Fallback: English
    messages: { en, es },
})

Usage

vue
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>

<template>
  <h1>{{ t('members.title') }}</h1>
  <p>{{ t('validation.minLength', { min: 8 }) }}</p>
</template>

Locale File Structure

Both locales/en.json and locales/es.json must contain every key. Sample structure:

json
{
  "nav": { "home": "Home", "users": "Users", "members": "Members" },
  "buttons": { "save": "Save", "cancel": "Cancel", "delete": "Delete" },
  "forms": { "email": "Email", "password": "Password" },
  "validation": { "required": "This field is required", "minLength": "..." },
  "toast": { "networkError": "...", "sessionExpired": "..." },
  "login": { "title": "Welcome back", "submit": "Sign in" },
  "members": { "title": "Members", "created": "Member created" }
}

Critical: The @ character must be escaped as {'@'} in locale values. vue-i18n reserves @ for linked message syntax.


Error Handling

Error Classification

lib/error-utils.ts provides three functions for error extraction:

FunctionReturnsUse Case
isNetworkError(error)booleanDetect API unreachable, timeout, DNS failure
extractApiError(error)ApiError | nullExtract structured { code, message } from response
extractErrorMessage(error)stringUser-friendly message (API → Error → generic fallback)

Error Flow

API call fails

    ├─ Network error (no response)?
    │   └─ showNetworkErrorToast() → deduplicated toast with i18n key

    ├─ 401 Unauthorized?
    │   └─ Response interceptor handles (refresh or redirect)

    └─ Other API error (400, 403, 404, 409, 500)?
        └─ Store/composable extracts { code, message }
            └─ Component displays via error ref or toast

Toast Notifications

lib/toast-notifications.ts provides deduplicated toast helpers using vue-sonner:

typescript
showNetworkErrorToast()     // id: 'network-error' (prevents duplicates)
showSessionExpiredToast()   // id: 'session-expired'

Type Definitions

All TypeScript interfaces live in types/. Each file corresponds to a business domain:

FileKey Types
types/api.tsApiError, PaginatedResult<T>, PaginationParams
types/auth.tsUser, LoginRequest, LoginResponse, RegisterRequest
types/admin.tsAdminUser, Role, RoleDetail, PermissionGroup
types/members.tsMember, MemberListItem, MemberStats, SubscriptionPlan, Promotion, LeaderboardEntry (80+ interfaces)
types/sidebar.tsModuleDefinition, NavItem, LucideIcon

Generic API Types

typescript
export interface ApiError {
    code: string
    message: string
}

export interface PaginatedResult<T> {
    items: T[]
    totalCount: number
    pageNumber: number
    pageSize: number
    totalPages: number
}

export interface PaginationParams {
    pageNumber?: number
    pageSize?: number
    orderBy?: string
    orderDirection?: 'asc' | 'desc'
}

Testing Strategy

Unit Tests (Vitest)

Environment: jsdom Location: test/ directory, mirroring the source structure Setup: vitest.setup.ts installs a global i18n plugin and clears mocks after each test

typescript
// vitest.setup.ts
export const testI18n = createI18n({
    legacy: false,
    locale: 'en',
    fallbackLocale: 'es',
    messages: { en, es },
})

config.global.plugins = [testI18n]
afterEach(() => vi.clearAllMocks())

Test directories:

DirectoryWhat it tests
test/components/Layout and shared components
test/components/admin/Admin domain components
test/components/auth/Auth domain components
test/components/club/Club domain components
test/components/ui/shadcn-vue primitives
test/locales/i18n key completeness

E2E Tests (Playwright)

Location: e2e/ directory Projects:

ProjectPurpose
setupAuthenticates and stores state in e2e/.auth/admin.json
adminRuns authenticated tests using stored auth state
chromium, firefox, webkitStandard browser matrix

Dev server: bun run dev on localhost:5173 (local) or bun run preview on localhost:4173 (CI)


Build Configuration

Vite

typescript
// vite.config.ts
{
    define: { __APP_VERSION__: JSON.stringify(pkg.version) },
    plugins: [vue(), tailwindcss(), vueDevTools()],
    resolve: { alias: { '@': fileURLToPath(new URL('./', import.meta.url)) } },
    server: {
        proxy: {
            '/api': { target: 'http://localhost:5000', changeOrigin: true },
            '/health': { target: 'http://localhost:5000', changeOrigin: true },
            '/scalar': { target: 'http://localhost:5000', changeOrigin: true },
        },
    },
}

Key points:

  • @ alias resolves to the project root (enables @/stores/auth imports)
  • Dev server proxies /api to the .NET backend at localhost:5000
  • Tailwind CSS v4 runs as a Vite plugin (no tailwind.config.js file)

Data Flow Patterns

Fetching a Paginated List

1. User navigates to /club/members
2. MembersView.vue mounts
3. Component calls membersStore.fetchMembers()
4. Store calls membersApi.listMembers(params)
5. API function: api.get<PaginatedResult<MemberListItem>>('/members', { params })
6. Request interceptor attaches Bearer token
7. Response arrives; store updates members[] + pagination
8. Component renders DataTable with store data
9. User clicks page 2 → store.fetchMembers({ pageNumber: 2 })

Submitting a Form

1. User fills PlanForm and clicks Save
2. onSubmit() calls useFormValidation().handleSubmit()
3. Composable sets isLoading=true, error=null
4. Inside callback: plansStore.createPlan(formData)
5. Store calls plansApi.createPlan(data)
6. On success: toast + router.push('/club/plans/{id}')
7. On error: composable extracts error message → component displays it

Permission-Based Navigation

1. Route meta: permissions: ['users:read']
2. permissionGuard calls authStore.hasPermission('users:read')
3. Store checks: wildcard '*' present? → allow all
4. Otherwise: 'users:read' in permissions[]?
5. If denied: redirect to /unauthorized
6. If allowed: render view

Quick Reference

Add a New View

  1. Create views/{module}/{Name}View.vue with <script setup lang="ts">
  2. Add route to router/index.ts with meta: { requiresAuth: true, permissions: [...], layout: 'management' }
  3. Add i18n keys to both locales/en.json and locales/es.json
  4. Create test at test/components/{module}/{Name}View.spec.ts

Add a New Store

  1. Create stores/{name}.ts
  2. Use defineStore('name', () => {...}) pattern
  3. State with ref(), getters with computed(), actions as plain functions
  4. Export all public state, getters, and actions from the setup function
  5. Import in components: const store = use{Name}Store()

Add a New API Module

  1. Create lib/{feature}-api.ts
  2. Import the shared Axios instance: import api from './api'
  3. Define typed functions: api.get<ResponseType>('/path', { params })
  4. Create corresponding types in types/{feature}.ts
  5. Import in stores: const { data } = await featureApi.someAction()

Add a New Component

  1. Create components/{domain}/{Name}.vue with PascalCase filename
  2. Use <script setup lang="ts"> — never Options API
  3. Follow SFC order: script → template → style scoped
  4. Use shadcn-vue primitives for UI, lucide-vue-next for icons
  5. All user-visible strings via t('key') with i18n
  6. Create test at test/components/{domain}/{Name}.spec.ts

Add a New Composable

  1. Create composables/use{Name}.ts
  2. Export a function that returns reactive state and/or methods
  3. Use ref(), computed(), and watch() for reactivity
  4. Import in components: const { state, action } = use{Name}()

Add a shadcn-vue Component

bash
cd sites/Wilo.App && bunx --bun shadcn-vue@latest add <component>

Run from the project folder. Uses --bun flag to ensure Bun runtime.

Add a New Module to the Sidebar

  1. Add ModuleDefinition entry to config/modules.ts
  2. Define routes in router/index.ts with appropriate permissions
  3. Add i18n keys for module name and nav items
  4. Create views, stores, API modules, and components as needed