Appearance
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
| Principle | Implementation |
|---|---|
| Composition API | <script setup lang="ts"> in every SFC — Options API is prohibited |
| Single File Components | Script → Template → Style scoped order in every .vue file |
| Setup Function Stores | defineStore('name', () => {...}) — no options-style stores |
| Feature-Based Organization | Components, views, stores, and API modules grouped by business domain |
| Type Safety | Strict TypeScript, Zod validation, typed API responses |
| i18n from Day 1 | Every 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:
| Layout | Used for | Structure |
|---|---|---|
blank | Login, setup, verify email, reset password | Centered card, no sidebar |
management | All authenticated pages | SidebarProvider + AppSidebar + AppHeader + main slot |
Route Groups
Public routes (blank layout, no auth required):
| Path | View | Purpose |
|---|---|---|
/login | LoginView | Email/password authentication |
/verify-email | VerifyEmailView | Email verification callback |
/forgot-password | ForgotPasswordView | Request password reset |
/reset-password | ResetPasswordView | Set new password via token |
/setup | SetupView | First-time platform setup wizard |
Platform module (management layout, auth required):
| Path | View | Permissions |
|---|---|---|
/platform | HomeView | — |
/platform/users | UsersView | users:read, roles:read |
/platform/roles | RolesView | roles:read, permissions:read |
/platform/password-policy | PasswordPolicyView | settings:write |
Club Wilo module (management layout, auth required):
| Path | View | Permissions |
|---|---|---|
/club | ClubDashboardView | members:read |
/club/members | MembersView | members:read |
/club/members/new | MemberCreateView | members:write |
/club/members/:id | MemberDetailView | members:read |
/club/members/:id/subscription | MemberSubscriptionView | subscriptions:read |
/club/plans | PlansView | plans:read |
/club/plans/new | PlanCreateView | plans:write |
/club/promotions | PromotionsView | promotions:read |
/club/promotions/new | PromotionCreateView | promotions:write |
/club/referrals | ReferralsView | referrals:read |
/club/referrals/leaderboard | ReferralLeaderboardView | referrals:read |
Navigation Guards
Guards run sequentially on every navigation. Defined in router/guards.ts and registered in router/index.ts:
setupGuard → authGuard → permissionGuard → forceChangePasswordGuard| Guard | Responsibility | Redirect |
|---|---|---|
setupGuard | Checks if platform setup is complete | /setup if incomplete |
authGuard | Redirects unauthenticated users; triggers silent refresh on first navigation | /login if unauthenticated |
permissionGuard | Checks route.meta.permissions against user's permission set | /unauthorized if denied |
forceChangePasswordGuard | Redirects 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:
| Store | State | Key Actions |
|---|---|---|
useMembersStore | members[], selectedMember, stats, pagination | fetchMembers, fetchMember, changeStatus, deleteMember |
usePlansStore | plans[], selectedPlan | fetchPlans, createPlan, updatePlan, deactivatePlan |
usePromotionsStore | promotions[], selectedPromotion | fetchPromotions, createPromotion, validateCode |
useReferralsStore | leaderboard[], stats, overview | fetchLeaderboard, fetchStats, generateCode |
useSidebarStore | activeModule (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 refreshThundering 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),
// ...
}| Module | File | Endpoints |
|---|---|---|
| Authentication | auth-api.ts | login, register, refresh, logout, verify email, forgot/reset password, profile, setup |
| Members | members-api.ts | list, get, create, update, delete, change status, stats, export |
| Plans | plans-api.ts | list, get, create, update, deactivate |
| Subscriptions | subscriptions-api.ts | enroll, subscribe, get subscription, cancel, renew, change plan |
| Promotions | promotions-api.ts | list, get, create, update, deactivate, validate code, usage |
| Referrals | referrals-api.ts | generate code, validate, apply, leaderboard, stats, overview |
| Admin | admin-api.ts | users 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 /platformSilent 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 /loginAutomatic 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 token | Memory (ref in store) | Memory (ref in store) |
| Refresh token | HttpOnly cookie (set by server) | Capacitor Preferences (encrypted) |
| Refresh call | Cookie sent automatically | Token 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 /loginComponent Organization
Component Directories
| Directory | Purpose | Editing 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:
| Function | Returns | Use Case |
|---|---|---|
isNetworkError(error) | boolean | Detect API unreachable, timeout, DNS failure |
extractApiError(error) | ApiError | null | Extract structured { code, message } from response |
extractErrorMessage(error) | string | User-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 toastToast 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:
| File | Key Types |
|---|---|
types/api.ts | ApiError, PaginatedResult<T>, PaginationParams |
types/auth.ts | User, LoginRequest, LoginResponse, RegisterRequest |
types/admin.ts | AdminUser, Role, RoleDetail, PermissionGroup |
types/members.ts | Member, MemberListItem, MemberStats, SubscriptionPlan, Promotion, LeaderboardEntry (80+ interfaces) |
types/sidebar.ts | ModuleDefinition, 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:
| Directory | What 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:
| Project | Purpose |
|---|---|
setup | Authenticates and stores state in e2e/.auth/admin.json |
admin | Runs authenticated tests using stored auth state |
chromium, firefox, webkit | Standard 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/authimports)- Dev server proxies
/apito the .NET backend at localhost:5000 - Tailwind CSS v4 runs as a Vite plugin (no
tailwind.config.jsfile)
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 itPermission-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 viewQuick Reference
Add a New View
- Create
views/{module}/{Name}View.vuewith<script setup lang="ts"> - Add route to
router/index.tswithmeta: { requiresAuth: true, permissions: [...], layout: 'management' } - Add i18n keys to both
locales/en.jsonandlocales/es.json - Create test at
test/components/{module}/{Name}View.spec.ts
Add a New Store
- Create
stores/{name}.ts - Use
defineStore('name', () => {...})pattern - State with
ref(), getters withcomputed(), actions as plain functions - Export all public state, getters, and actions from the setup function
- Import in components:
const store = use{Name}Store()
Add a New API Module
- Create
lib/{feature}-api.ts - Import the shared Axios instance:
import api from './api' - Define typed functions:
api.get<ResponseType>('/path', { params }) - Create corresponding types in
types/{feature}.ts - Import in stores:
const { data } = await featureApi.someAction()
Add a New Component
- Create
components/{domain}/{Name}.vuewith PascalCase filename - Use
<script setup lang="ts">— never Options API - Follow SFC order: script → template → style scoped
- Use shadcn-vue primitives for UI,
lucide-vue-nextfor icons - All user-visible strings via
t('key')with i18n - Create test at
test/components/{domain}/{Name}.spec.ts
Add a New Composable
- Create
composables/use{Name}.ts - Export a function that returns reactive state and/or methods
- Use
ref(),computed(), andwatch()for reactivity - 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
- Add
ModuleDefinitionentry toconfig/modules.ts - Define routes in
router/index.tswith appropriate permissions - Add i18n keys for module name and nav items
- Create views, stores, API modules, and components as needed