chore(rules): rewrite 101_vue.mdc as slim principles file
Drops 17 KB of embedded code templates that had drifted from actual implementations in apps/app/src/ (auth store template still used localStorage; portal router guards still showed dual-mode logic that was consolidated to /portal/* routes within apps/app in PR-B1/B2a). Slim rewrite: principles + file structure + pointers to actual reference code in apps/app/src/. Globs narrowed to apps/app/**/* since apps/portal/ no longer exists. Vuexy component selection deferred to dev-docs/VUEXY_COMPONENTS.md as canonical registry. Net: ~17 KB -> ~3 KB, less drift surface, points at living code instead of duplicating it.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
---
|
||||
description: Vue 3, TypeScript, and Vuexy patterns for Crewli platform
|
||||
globs: ["apps/**/*.{vue,ts,tsx}"]
|
||||
description: Vue 3, TypeScript, and Vuexy patterns for Crewli
|
||||
globs: ["apps/app/**/*.{vue,ts,tsx}"]
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
@@ -8,675 +8,78 @@ alwaysApply: true
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Composition API only** - Always `<script setup lang="ts">`
|
||||
2. **TypeScript strict mode** - No `any` types
|
||||
3. **TanStack Query for API** - Never raw axios in components
|
||||
4. **Pinia for client state** - Server data stays in TanStack Query
|
||||
5. **Vuexy/Vuetify components** - Never custom CSS if a Vuetify class exists
|
||||
6. **VeeValidate + Zod** - For all form validation
|
||||
7. **Mobile-first** - Minimum 375px width
|
||||
1. **Composition API only** — always `<script setup lang="ts">`, never Options API
|
||||
2. **No `any` types** — use proper typing or `unknown` + narrowing
|
||||
3. **TanStack Query for API** — never raw axios in components
|
||||
4. **Pinia for cross-component client state** — server data lives in TanStack Query, never duplicated in stores
|
||||
5. **Vuetify components first** — custom CSS only when no Vuetify class fits the use case
|
||||
6. **VeeValidate + Zod** for all form validation
|
||||
7. **Mobile-first** — minimum 375px width, responsive at every breakpoint
|
||||
|
||||
## App-Specific Rules
|
||||
## File structure
|
||||
|
||||
### `apps/app/` (Organizer + Platform Admin - Main App)
|
||||
- Sidebar nav customized for Crewli structure
|
||||
- Remove Vuexy demo/customizer components
|
||||
- Full Vuetify component usage
|
||||
- 90% of development work happens here
|
||||
- Super admin functionality under `/platform/*` routes for `super_admin` users
|
||||
|
||||
### `apps/portal/` (External Portal)
|
||||
- Stripped Vuexy: no sidebar, no customizer, no dark mode toggle
|
||||
- Custom layout: top-bar with event logo + name
|
||||
- Uses Vuetify components + Vuexy SCSS variables
|
||||
- Two access modes: login (volunteers) and token (artists/suppliers)
|
||||
- Mobile-first design
|
||||
|
||||
## Vuexy Folder Rules
|
||||
|
||||
### Never Modify
|
||||
```
|
||||
src/@core/ # Vuexy core
|
||||
src/@layouts/ # Vuexy layouts
|
||||
apps/app/src/
|
||||
├── lib/axios.ts # Single axios instance (do not duplicate)
|
||||
├── composables/api/use*.ts # TanStack Query composables (one per resource)
|
||||
├── stores/use*Store.ts # Pinia stores — client state only
|
||||
├── types/*.ts # TypeScript interfaces (mirror backend Resources)
|
||||
├── pages/ # File-based routing via unplugin-vue-router
|
||||
├── layouts/ # Layout components
|
||||
├── components/ # Reusable components
|
||||
└── @core/ # Vuexy core — DO NOT MODIFY
|
||||
```
|
||||
|
||||
### Customize
|
||||
```
|
||||
src/
|
||||
├── components/ # Custom components
|
||||
├── composables/ # useModule.ts composables (TanStack Query)
|
||||
├── layouts/ # App layout customizations
|
||||
├── lib/ # axios.ts (SINGLE axios instance per app)
|
||||
├── navigation/ # Sidebar menu items
|
||||
├── pages/ # Page components
|
||||
├── plugins/ # vue-query, casl, vuetify
|
||||
├── stores/ # Pinia stores (client state only)
|
||||
└── types/ # TypeScript interfaces
|
||||
```
|
||||
## Reference patterns (read these for templates)
|
||||
|
||||
## File Templates
|
||||
For working examples in the actual codebase:
|
||||
|
||||
### Axios Instance (ONE per app)
|
||||
- **Composable pattern:** `apps/app/src/composables/api/useEvents.ts`
|
||||
- **Pinia store pattern:** `apps/app/src/stores/useAuthStore.ts`
|
||||
- **Page pattern:** `apps/app/src/pages/events/index.vue`
|
||||
- **Form pattern:** `apps/app/src/components/events/CreateEventDialog.vue`
|
||||
- **Layout pattern:** `apps/app/src/layouts/OrganizerLayout.vue`
|
||||
|
||||
```typescript
|
||||
// src/lib/axios.ts
|
||||
import axios from 'axios'
|
||||
import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
For Vuexy component selection, consult `dev-docs/VUEXY_COMPONENTS.md` — the registry of @core wrappers and patterns. Always check that registry before writing a custom component.
|
||||
|
||||
const api: AxiosInstance = axios.create({
|
||||
baseURL: `${import.meta.env.VITE_API_URL}/api/v1`,
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
timeout: 30000,
|
||||
})
|
||||
For auth and routing, see `dev-docs/AUTH_ARCHITECTURE.md` (httpOnly cookies, dual-axios for portal-token routes, route guard logic).
|
||||
|
||||
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
const authStore = useAuthStore()
|
||||
if (authStore.token) {
|
||||
config.headers.Authorization = `Bearer ${authStore.token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
## Strict rules
|
||||
|
||||
api.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (error.response?.status === 401) {
|
||||
const authStore = useAuthStore()
|
||||
authStore.logout()
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
### TypeScript
|
||||
- Use `import type { ... }` for type-only imports
|
||||
- Mirror backend PHP Enums as const objects with `as const` in `apps/app/src/types/`
|
||||
- Generic API response shape: `{ data: T, meta?: PaginationMeta }`
|
||||
|
||||
export { api }
|
||||
```
|
||||
### Architecture
|
||||
- Components never import axios directly — always via composables
|
||||
- Composables call axios via the singleton in `apps/app/src/lib/axios.ts`
|
||||
- Mutations invalidate query keys after success
|
||||
- No prop drilling — use Pinia stores when state crosses two component boundaries
|
||||
|
||||
### TypeScript Types
|
||||
### UI
|
||||
- Three states for every list view: **loading** (VSkeletonLoader), **error** (VAlert with retry button), **empty** (helpful message explaining what action to take)
|
||||
- Custom CSS forbidden when a Vuetify utility class exists
|
||||
- Tables on mobile (<768px) collapse to VList or card view — never horizontal scroll without a visual indicator
|
||||
|
||||
```typescript
|
||||
// src/types/events.ts
|
||||
### Forms
|
||||
- Zod schema mirrors backend FormRequest validation
|
||||
- Errors shown inline via VeeValidate's `errors` object
|
||||
- Submit button disabled while `isPending`
|
||||
|
||||
export type EventStatus = 'draft' | 'published' | 'registration_open' | 'buildup' | 'showday' | 'teardown' | 'closed'
|
||||
export type PersonStatus = 'invited' | 'applied' | 'pending' | 'approved' | 'rejected' | 'no_show'
|
||||
export type BookingStatus = 'concept' | 'requested' | 'option' | 'confirmed' | 'contracted' | 'cancelled'
|
||||
export type ShiftAssignmentStatus = 'pending_approval' | 'approved' | 'rejected' | 'cancelled' | 'completed'
|
||||
export type CrowdSystemType = 'CREW' | 'GUEST' | 'ARTIST' | 'VOLUNTEER' | 'PRESS' | 'PARTNER' | 'SUPPLIER'
|
||||
### Routing
|
||||
- File-based routing via unplugin-vue-router
|
||||
- Guards in `apps/app/src/plugins/1.router/guards.ts`
|
||||
- Portal routes are at `/portal/*` (within apps/app), NOT a separate SPA
|
||||
- Platform admin routes are at `/platform/*`, gated by `super_admin` role
|
||||
|
||||
export interface Organisation {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
billing_status: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
## Avoid
|
||||
|
||||
export interface Event {
|
||||
id: string
|
||||
organisation_id: string
|
||||
name: string
|
||||
slug: string
|
||||
start_date: string
|
||||
end_date: string
|
||||
timezone: string
|
||||
status: EventStatus
|
||||
status_label: string
|
||||
status_color: string
|
||||
festival_sections?: FestivalSection[]
|
||||
persons_count?: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface FestivalSection {
|
||||
id: string
|
||||
event_id: string
|
||||
name: string
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export interface TimeSlot {
|
||||
id: string
|
||||
event_id: string
|
||||
name: string
|
||||
person_type: CrowdSystemType
|
||||
date: string
|
||||
start_time: string
|
||||
end_time: string
|
||||
}
|
||||
|
||||
export interface Shift {
|
||||
id: string
|
||||
festival_section_id: string
|
||||
time_slot_id: string
|
||||
location_id: string | null
|
||||
slots_total: number
|
||||
slots_open_for_claiming: number
|
||||
slots_filled: number
|
||||
fill_rate: number
|
||||
status: string
|
||||
festival_section?: FestivalSection
|
||||
time_slot?: TimeSlot
|
||||
assignments?: ShiftAssignment[]
|
||||
}
|
||||
|
||||
export interface ShiftAssignment {
|
||||
id: string
|
||||
shift_id: string
|
||||
person_id: string
|
||||
time_slot_id: string
|
||||
status: ShiftAssignmentStatus
|
||||
auto_approved: boolean
|
||||
person?: Person
|
||||
}
|
||||
|
||||
export interface Person {
|
||||
id: string
|
||||
event_id: string
|
||||
crowd_type_id: string
|
||||
user_id: string | null
|
||||
name: string
|
||||
email: string
|
||||
phone: string | null
|
||||
status: PersonStatus
|
||||
is_blacklisted: boolean
|
||||
crowd_type?: CrowdType
|
||||
}
|
||||
|
||||
export interface CrowdType {
|
||||
id: string
|
||||
organisation_id: string
|
||||
name: string
|
||||
system_type: CrowdSystemType
|
||||
color: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export interface Artist {
|
||||
id: string
|
||||
event_id: string
|
||||
name: string
|
||||
booking_status: BookingStatus
|
||||
star_rating: number
|
||||
}
|
||||
|
||||
// API response types
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[]
|
||||
meta: {
|
||||
current_page: number
|
||||
per_page: number
|
||||
total: number
|
||||
last_page: number
|
||||
}
|
||||
}
|
||||
|
||||
// Form types
|
||||
export interface CreateEventData {
|
||||
organisation_id: string
|
||||
name: string
|
||||
slug: string
|
||||
start_date: string
|
||||
end_date: string
|
||||
timezone?: string
|
||||
status?: EventStatus
|
||||
}
|
||||
|
||||
export interface UpdateEventData extends Partial<CreateEventData> {}
|
||||
```
|
||||
|
||||
### TanStack Query Setup
|
||||
|
||||
```typescript
|
||||
// src/plugins/vue-query.ts
|
||||
import type { VueQueryPluginOptions } from '@tanstack/vue-query'
|
||||
import { QueryClient } from '@tanstack/vue-query'
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const vueQueryPluginOptions: VueQueryPluginOptions = {
|
||||
queryClient,
|
||||
}
|
||||
```
|
||||
|
||||
### Composable (useEvents)
|
||||
|
||||
```typescript
|
||||
// src/composables/useEvents.ts
|
||||
import { computed } from 'vue'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query'
|
||||
import { api } from '@/lib/axios'
|
||||
import type { Event, CreateEventData, UpdateEventData, PaginatedResponse } from '@/types/events'
|
||||
|
||||
export function useEventList(organisationId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['organisations', organisationId, 'events'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<PaginatedResponse<Event>>(
|
||||
`/organisations/${organisationId}/events`
|
||||
)
|
||||
return data
|
||||
},
|
||||
enabled: !!organisationId,
|
||||
})
|
||||
}
|
||||
|
||||
export function useEventDetail(eventId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['events', eventId],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<{ data: Event }>(`/events/${eventId}`)
|
||||
return data.data
|
||||
},
|
||||
enabled: !!eventId,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateEvent() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (payload: { organisationId: string; data: CreateEventData }) => {
|
||||
const { data } = await api.post<{ data: Event }>(
|
||||
`/organisations/${payload.organisationId}/events`,
|
||||
payload.data,
|
||||
)
|
||||
return data.data
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['organisations', variables.organisationId, 'events'],
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateEvent() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (payload: { eventId: string; data: UpdateEventData }) => {
|
||||
const { data } = await api.put<{ data: Event }>(
|
||||
`/events/${payload.eventId}`,
|
||||
payload.data,
|
||||
)
|
||||
return data.data
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['events', data.id] })
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['organisations', data.organisation_id, 'events'],
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteEvent() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (eventId: string) => {
|
||||
await api.delete(`/events/${eventId}`)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['events'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Pinia Store (Auth)
|
||||
|
||||
```typescript
|
||||
// src/stores/useAuthStore.ts
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { api } from '@/lib/axios'
|
||||
import type { Organisation } from '@/types/events'
|
||||
|
||||
interface AuthUser {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
timezone: string
|
||||
locale: string
|
||||
organisations: Organisation[]
|
||||
event_roles: Array<{ event_id: string; role: string }>
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref<AuthUser | null>(null)
|
||||
const token = ref<string | null>(localStorage.getItem('auth_token'))
|
||||
const currentOrganisationId = ref<string | null>(localStorage.getItem('current_organisation_id'))
|
||||
|
||||
const isAuthenticated = computed(() => !!token.value && !!user.value)
|
||||
const currentOrganisation = computed(() =>
|
||||
user.value?.organisations.find(o => o.id === currentOrganisationId.value) ?? null
|
||||
)
|
||||
|
||||
async function login(email: string, password: string): Promise<boolean> {
|
||||
try {
|
||||
const { data } = await api.post('/auth/login', { email, password })
|
||||
user.value = data.data.user
|
||||
token.value = data.data.token
|
||||
localStorage.setItem('auth_token', data.data.token)
|
||||
|
||||
if (data.data.user.organisations.length > 0) {
|
||||
setCurrentOrganisation(data.data.user.organisations[0].id)
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMe(): Promise<boolean> {
|
||||
if (!token.value) return false
|
||||
try {
|
||||
const { data } = await api.get('/auth/me')
|
||||
user.value = data.data
|
||||
return true
|
||||
} catch {
|
||||
logout()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function setCurrentOrganisation(orgId: string) {
|
||||
currentOrganisationId.value = orgId
|
||||
localStorage.setItem('current_organisation_id', orgId)
|
||||
}
|
||||
|
||||
function logout() {
|
||||
user.value = null
|
||||
token.value = null
|
||||
currentOrganisationId.value = null
|
||||
localStorage.removeItem('auth_token')
|
||||
localStorage.removeItem('current_organisation_id')
|
||||
}
|
||||
|
||||
return {
|
||||
user, token, currentOrganisationId,
|
||||
isAuthenticated, currentOrganisation,
|
||||
login, fetchMe, setCurrentOrganisation, logout,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Page Component (Event List)
|
||||
|
||||
```vue
|
||||
<!-- src/pages/events/index.vue -->
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import { useEventList } from '@/composables/useEvents'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const organisationId = computed(() => authStore.currentOrganisationId ?? '')
|
||||
const { data, isLoading, isError, error } = useEventList(organisationId.value)
|
||||
|
||||
const events = computed(() => data.value?.data ?? [])
|
||||
|
||||
function getStatusColor(status: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
draft: 'secondary',
|
||||
published: 'info',
|
||||
registration_open: 'primary',
|
||||
buildup: 'warning',
|
||||
showday: 'success',
|
||||
teardown: 'warning',
|
||||
closed: 'secondary',
|
||||
}
|
||||
return colors[status] ?? 'secondary'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Page Header -->
|
||||
<div class="d-flex justify-space-between align-center mb-6">
|
||||
<div>
|
||||
<h4 class="text-h4 mb-1">Events</h4>
|
||||
<p class="text-body-1 text-medium-emphasis">
|
||||
Manage events for your organisation
|
||||
</p>
|
||||
</div>
|
||||
<VBtn
|
||||
color="primary"
|
||||
prepend-icon="tabler-plus"
|
||||
:to="{ name: 'events-create' }"
|
||||
>
|
||||
Create Event
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<VCard v-if="isLoading">
|
||||
<VCardText class="text-center py-8">
|
||||
<VProgressCircular indeterminate color="primary" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Error State -->
|
||||
<VAlert v-else-if="isError" type="error" class="mb-4">
|
||||
{{ error?.message ?? 'Failed to load events' }}
|
||||
</VAlert>
|
||||
|
||||
<!-- Events Table -->
|
||||
<VCard v-else>
|
||||
<VDataTable
|
||||
:items="events"
|
||||
:headers="[
|
||||
{ title: 'Name', key: 'name' },
|
||||
{ title: 'Dates', key: 'start_date' },
|
||||
{ title: 'Status', key: 'status' },
|
||||
{ title: 'Actions', key: 'actions', sortable: false },
|
||||
]"
|
||||
>
|
||||
<template #item.name="{ item }">
|
||||
<RouterLink :to="{ name: 'events-show', params: { id: item.id } }">
|
||||
{{ item.name }}
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<template #item.start_date="{ item }">
|
||||
{{ item.start_date }} - {{ item.end_date }}
|
||||
</template>
|
||||
|
||||
<template #item.status="{ item }">
|
||||
<VChip :color="getStatusColor(item.status)" size="small">
|
||||
{{ item.status_label }}
|
||||
</VChip>
|
||||
</template>
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<VBtn
|
||||
icon
|
||||
variant="text"
|
||||
size="small"
|
||||
:to="{ name: 'events-edit', params: { id: item.id } }"
|
||||
>
|
||||
<VIcon icon="tabler-edit" />
|
||||
</VBtn>
|
||||
</template>
|
||||
</VDataTable>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Navigation Menu (Organizer App)
|
||||
|
||||
```typescript
|
||||
// src/navigation/vertical/index.ts
|
||||
import type { VerticalNavItems } from '@/@layouts/types'
|
||||
|
||||
export default [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
to: { name: 'dashboard' },
|
||||
icon: { icon: 'tabler-smart-home' },
|
||||
},
|
||||
{ heading: 'Event Management' },
|
||||
{
|
||||
title: 'Events',
|
||||
to: { name: 'events' },
|
||||
icon: { icon: 'tabler-calendar-event' },
|
||||
},
|
||||
{
|
||||
title: 'Festival Sections',
|
||||
to: { name: 'festival-sections' },
|
||||
icon: { icon: 'tabler-layout-grid' },
|
||||
},
|
||||
{
|
||||
title: 'Time Slots & Shifts',
|
||||
to: { name: 'shifts' },
|
||||
icon: { icon: 'tabler-clock' },
|
||||
},
|
||||
{ heading: 'People' },
|
||||
{
|
||||
title: 'Persons',
|
||||
to: { name: 'persons' },
|
||||
icon: { icon: 'tabler-users' },
|
||||
},
|
||||
{
|
||||
title: 'Artists',
|
||||
to: { name: 'artists' },
|
||||
icon: { icon: 'tabler-music' },
|
||||
},
|
||||
{
|
||||
title: 'Volunteers',
|
||||
to: { name: 'volunteers' },
|
||||
icon: { icon: 'tabler-heart-handshake' },
|
||||
},
|
||||
{ heading: 'Operations' },
|
||||
{
|
||||
title: 'Accreditation',
|
||||
to: { name: 'accreditation' },
|
||||
icon: { icon: 'tabler-id-badge-2' },
|
||||
},
|
||||
{
|
||||
title: 'Briefings',
|
||||
to: { name: 'briefings' },
|
||||
icon: { icon: 'tabler-mail' },
|
||||
},
|
||||
{
|
||||
title: 'Mission Control',
|
||||
to: { name: 'mission-control' },
|
||||
icon: { icon: 'tabler-broadcast' },
|
||||
},
|
||||
{ heading: 'Insights' },
|
||||
{
|
||||
title: 'Reports',
|
||||
to: { name: 'reports' },
|
||||
icon: { icon: 'tabler-chart-bar' },
|
||||
},
|
||||
] as VerticalNavItems
|
||||
```
|
||||
|
||||
## Forms with VeeValidate + Zod
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { z } from 'zod'
|
||||
import { useCreateEvent } from '@/composables/useEvents'
|
||||
|
||||
const schema = toTypedSchema(
|
||||
z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
slug: z.string().min(1, 'Slug is required'),
|
||||
start_date: z.string().min(1, 'Start date is required'),
|
||||
end_date: z.string().min(1, 'End date is required'),
|
||||
timezone: z.string().default('Europe/Amsterdam'),
|
||||
})
|
||||
)
|
||||
|
||||
const { handleSubmit, errors, defineField } = useForm({ validationSchema: schema })
|
||||
const [name, nameAttrs] = defineField('name')
|
||||
const [startDate, startDateAttrs] = defineField('start_date')
|
||||
|
||||
const { mutate: createEvent, isPending } = useCreateEvent()
|
||||
|
||||
const onSubmit = handleSubmit(values => {
|
||||
createEvent({
|
||||
organisationId: authStore.currentOrganisationId!,
|
||||
data: values as CreateEventData,
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit="onSubmit">
|
||||
<VTextField
|
||||
v-model="name"
|
||||
v-bind="nameAttrs"
|
||||
label="Event Name"
|
||||
:error-messages="errors.name"
|
||||
/>
|
||||
<VTextField
|
||||
v-model="startDate"
|
||||
v-bind="startDateAttrs"
|
||||
label="Start Date"
|
||||
type="date"
|
||||
:error-messages="errors.start_date"
|
||||
/>
|
||||
<VBtn type="submit" color="primary" :loading="isPending">
|
||||
Create Event
|
||||
</VBtn>
|
||||
</form>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Always Use
|
||||
- `<script setup lang="ts">` for components
|
||||
- Props: `defineProps<{...}>()`
|
||||
- Emits: `defineEmits<{...}>()`
|
||||
- TanStack Query for all API calls via composables
|
||||
- Computed properties for derived state
|
||||
- Vuexy/Vuetify components (VBtn, VCard, VDataTable, VDialog, etc.)
|
||||
- `import type { ... }` for type-only imports
|
||||
- Status KPI tiles as clickable VCards on list pages
|
||||
- VSkeleton loader during loading
|
||||
- VAlert with retry on errors
|
||||
- Mobile: table collapses to VList below 768px
|
||||
|
||||
### Avoid
|
||||
- Options API
|
||||
- Options API (`export default { ... }`)
|
||||
- `any` types
|
||||
- Raw axios calls in components (use composables)
|
||||
- Inline styles (use Vuetify utility classes)
|
||||
- Raw axios calls in components
|
||||
- Inline styles
|
||||
- Direct DOM manipulation
|
||||
- Mutating props
|
||||
- Prop drilling (use Pinia stores)
|
||||
- Custom CSS when Vuetify class exists
|
||||
|
||||
## Portal Router Guards
|
||||
|
||||
```typescript
|
||||
// apps/portal/src/router/guards.ts
|
||||
export function determineAccessMode(route: RouteLocationNormalized): 'token' | 'login' | 'unauthenticated' {
|
||||
if (route.query.token) return 'token'
|
||||
if (authStore.isAuthenticated) return 'login'
|
||||
return 'unauthenticated'
|
||||
}
|
||||
|
||||
// Token-based: POST /api/v1/portal/token-auth { token: '...' } -> returns person context
|
||||
// Login-based: Same /api/v1/auth/login as app/
|
||||
```
|
||||
- Custom CSS when a Vuetify class exists
|
||||
- Hardcoded URLs or string-literal status values
|
||||
|
||||
Reference in New Issue
Block a user