Files
band-management/.cursor/rules/101_vue.mdc

1057 lines
25 KiB
Plaintext

---
description: Vue 3, TypeScript, and Vuexy patterns and conventions
globs: ["apps/**/*.{vue,ts,tsx}"]
alwaysApply: true
---
# Vue + Vuexy Rules
## Core Principles
1. **Composition API only** - Use `<script setup lang="ts">`
2. **TypeScript strict mode** - No `any` types
3. **Vue Query for API** - Use TanStack Query, not raw axios
4. **Pinia for state** - Shared state in stores
5. **Vuexy components** - Use Vuexy UI, don't reinvent
## Vuexy Structure
### Folders to Keep (Don't Modify)
```
src/
├── @core/ # Vuexy core - NEVER modify
├── @layouts/ # Vuexy layouts - NEVER modify
├── assets/styles/ # Vuexy styles - extend, don't replace
└── plugins/ # Vuexy plugins - keep as-is
```
### Folders to Customize
```
src/
├── components/ # Your custom components
├── composables/ # Your composables (useEvents, etc.)
├── layouts/ # App layout customizations
├── lib/ # API client, utilities
├── navigation/ # Menu items
├── pages/ # Your pages
├── router/ # Your routes
├── stores/ # Pinia stores
├── types/ # TypeScript types
└── views/ # Your views
```
## File Templates
### API Client
```typescript
// src/lib/api-client.ts
import axios from 'axios'
import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios'
const apiClient: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_URL,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
timeout: 30000,
})
// Request interceptor - add auth token
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem('auth_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
// Log in development
if (import.meta.env.DEV) {
console.log(`🚀 ${config.method?.toUpperCase()} ${config.url}`, config.data)
}
return config
},
(error) => Promise.reject(error)
)
// Response interceptor - handle errors
apiClient.interceptors.response.use(
(response) => {
if (import.meta.env.DEV) {
console.log(`✅ ${response.status} ${response.config.url}`, response.data)
}
return response
},
(error) => {
if (import.meta.env.DEV) {
console.error(`❌ ${error.response?.status} ${error.config?.url}`, error.response?.data)
}
// Handle 401 - redirect to login
if (error.response?.status === 401) {
localStorage.removeItem('auth_token')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export { apiClient }
```
### TypeScript Types
```typescript
// src/types/index.ts
// API Response wrapper
export interface ApiResponse<T = unknown> {
success: boolean
data: T
message?: string
meta?: {
pagination?: Pagination
}
}
export interface Pagination {
current_page: number
per_page: number
total: number
last_page: number
from: number
to: number
}
export interface ApiError {
success: false
message: string
errors?: Record<string, string[]>
}
// Auth
export interface LoginCredentials {
email: string
password: string
remember?: boolean
}
export interface AuthResponse {
user: User
token: string
}
// User
export type UserType = 'member' | 'customer'
export type UserRole = 'admin' | 'booking_agent' | 'music_manager' | 'member'
export type UserStatus = 'active' | 'inactive'
export interface User {
id: string
name: string
email: string
phone: string | null
bio: string | null
instruments: string[] | null
avatar_path: string | null
type: UserType
role: UserRole | null
status: UserStatus
created_at: string
updated_at: string
}
// Event
export type EventStatus = 'draft' | 'pending' | 'confirmed' | 'completed' | 'cancelled'
export type EventVisibility = 'private' | 'members' | 'public'
export type RsvpStatus = 'pending' | 'available' | 'unavailable' | 'tentative'
export interface Event {
id: string
title: string
description: string | null
event_date: string
start_time: string
end_time: string | null
load_in_time: string | null
soundcheck_time: string | null
fee: number | null
currency: string
status: EventStatus
status_label: string
visibility: EventVisibility
rsvp_deadline: string | null
notes: string | null
internal_notes: string | null
location: Location | null
customer: Customer | null
setlist: Setlist | null
invitations: EventInvitation[]
created_at: string
updated_at: string
}
export interface EventInvitation {
id: string
event_id: string
user_id: string
rsvp_status: RsvpStatus
rsvp_note: string | null
rsvp_responded_at: string | null
invited_at: string
user?: User
}
// Location
export interface Location {
id: string
name: string
address: string
city: string
postal_code: string | null
country: string
latitude: number | null
longitude: number | null
capacity: number | null
contact_name: string | null
contact_email: string | null
contact_phone: string | null
created_at: string
updated_at: string
}
// Customer
export type CustomerType = 'individual' | 'company'
export interface Customer {
id: string
name: string
company_name: string | null
type: CustomerType
email: string | null
phone: string | null
address: string | null
city: string | null
postal_code: string | null
country: string
is_portal_enabled: boolean
created_at: string
updated_at: string
}
// Music
export type AttachmentType = 'lyrics' | 'chords' | 'sheet_music' | 'audio' | 'other'
export interface MusicNumber {
id: string
title: string
artist: string | null
genre: string | null
duration_seconds: number | null
key: string | null
tempo_bpm: number | null
time_signature: string | null
lyrics: string | null
notes: string | null
tags: string[] | null
play_count: number
is_active: boolean
attachments: MusicAttachment[]
created_at: string
updated_at: string
}
export interface MusicAttachment {
id: string
music_number_id: string
file_name: string
original_name: string
file_path: string
file_type: AttachmentType
file_size: number
mime_type: string
created_at: string
}
// Setlist
export interface Setlist {
id: string
name: string
description: string | null
total_duration_seconds: number | null
is_template: boolean
is_archived: boolean
items: SetlistItem[]
created_at: string
updated_at: string
}
export interface SetlistItem {
id: string
setlist_id: string
music_number_id: string | null
position: number
set_number: number
is_break: boolean
break_duration_seconds: number | null
notes: string | null
music_number?: MusicNumber
}
// Form types for creating/updating
export interface CreateEventData {
title: string
description?: string
location_id?: string
customer_id?: string
setlist_id?: string
event_date: string
start_time: string
end_time?: string
fee?: number
status?: EventStatus
visibility?: EventVisibility
rsvp_deadline?: string
notes?: string
}
export interface UpdateEventData extends Partial<CreateEventData> {}
```
### Vue 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: (failureCount, error: any) => {
// Don't retry on 4xx errors
if (error?.response?.status >= 400 && error?.response?.status < 500) {
return false
}
return failureCount < 3
},
},
},
})
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 { apiClient } from '@/lib/api-client'
import type { Event, CreateEventData, UpdateEventData, ApiResponse } from '@/types'
export function useEvents() {
const queryClient = useQueryClient()
// Fetch all events
const eventsQuery = useQuery({
queryKey: ['events'],
queryFn: async () => {
const { data } = await apiClient.get<ApiResponse<Event[]>>('/events')
return data.data
},
})
// Fetch single event
const useEvent = (id: string) => useQuery({
queryKey: ['events', id],
queryFn: async () => {
const { data } = await apiClient.get<ApiResponse<Event>>(`/events/${id}`)
return data.data
},
enabled: !!id,
})
// Create event
const createMutation = useMutation({
mutationFn: async (eventData: CreateEventData) => {
const { data } = await apiClient.post<ApiResponse<Event>>('/events', eventData)
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] })
},
})
// Update event
const updateMutation = useMutation({
mutationFn: async ({ id, data }: { id: string; data: UpdateEventData }) => {
const response = await apiClient.put<ApiResponse<Event>>(`/events/${id}`, data)
return response.data.data
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['events'] })
queryClient.invalidateQueries({ queryKey: ['events', variables.id] })
},
})
// Delete event
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
await apiClient.delete(`/events/${id}`)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] })
},
})
return {
// Queries
events: computed(() => eventsQuery.data.value ?? []),
isLoading: eventsQuery.isLoading,
isError: eventsQuery.isError,
error: eventsQuery.error,
useEvent,
// Mutations
createEvent: createMutation.mutate,
updateEvent: updateMutation.mutate,
deleteEvent: deleteMutation.mutate,
isCreating: createMutation.isPending,
isUpdating: updateMutation.isPending,
isDeleting: deleteMutation.isPending,
}
}
```
### Pinia Store (Auth)
```typescript
// src/stores/auth.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { apiClient } from '@/lib/api-client'
import type { User, LoginCredentials, AuthResponse, ApiResponse } from '@/types'
export const useAuthStore = defineStore('auth', () => {
// State
const user = ref<User | null>(null)
const token = ref<string | null>(localStorage.getItem('auth_token'))
const isLoading = ref(false)
// Getters
const isAuthenticated = computed(() => !!token.value && !!user.value)
const isAdmin = computed(() => user.value?.role === 'admin')
const userName = computed(() => user.value?.name ?? '')
// Actions
async function login(credentials: LoginCredentials): Promise<boolean> {
isLoading.value = true
try {
const { data } = await apiClient.post<ApiResponse<AuthResponse>>(
'/auth/login',
credentials
)
if (data.success) {
user.value = data.data.user
token.value = data.data.token
localStorage.setItem('auth_token', data.data.token)
return true
}
return false
} catch (error) {
console.error('Login failed:', error)
return false
} finally {
isLoading.value = false
}
}
async function logout(): Promise<void> {
try {
await apiClient.post('/auth/logout')
} catch (error) {
// Ignore logout errors
} finally {
user.value = null
token.value = null
localStorage.removeItem('auth_token')
}
}
async function fetchUser(): Promise<boolean> {
if (!token.value) return false
isLoading.value = true
try {
const { data } = await apiClient.get<ApiResponse<User>>('/auth/user')
if (data.success) {
user.value = data.data
return true
}
return false
} catch (error) {
await logout()
return false
} finally {
isLoading.value = false
}
}
return {
// State
user,
token,
isLoading,
// Getters
isAuthenticated,
isAdmin,
userName,
// Actions
login,
logout,
fetchUser,
}
})
```
### Page Component
```vue
<!-- src/pages/events/index.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { useEvents } from '@/composables/useEvents'
const { events, isLoading, deleteEvent, isDeleting } = useEvents()
const showDeleteDialog = ref(false)
const eventToDelete = ref<string | null>(null)
function confirmDelete(id: string) {
eventToDelete.value = id
showDeleteDialog.value = true
}
function handleDelete() {
if (eventToDelete.value) {
deleteEvent(eventToDelete.value)
showDeleteDialog.value = false
eventToDelete.value = null
}
}
</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 your gigs and performances
</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" />
<p class="mt-4">Loading events...</p>
</VCardText>
</VCard>
<!-- Events Table -->
<VCard v-else>
<VTable>
<thead>
<tr>
<th>Title</th>
<th>Date</th>
<th>Location</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="event in events" :key="event.id">
<td>
<RouterLink :to="{ name: 'events-show', params: { id: event.id } }">
{{ event.title }}
</RouterLink>
</td>
<td>{{ event.event_date }}</td>
<td>{{ event.location?.name ?? '-' }}</td>
<td>
<VChip :color="getStatusColor(event.status)" size="small">
{{ event.status_label }}
</VChip>
</td>
<td>
<VBtn
icon
variant="text"
size="small"
:to="{ name: 'events-edit', params: { id: event.id } }"
>
<VIcon icon="tabler-edit" />
</VBtn>
<VBtn
icon
variant="text"
size="small"
color="error"
@click="confirmDelete(event.id)"
>
<VIcon icon="tabler-trash" />
</VBtn>
</td>
</tr>
</tbody>
</VTable>
</VCard>
<!-- Delete Confirmation Dialog -->
<VDialog v-model="showDeleteDialog" max-width="400">
<VCard>
<VCardTitle>Delete Event?</VCardTitle>
<VCardText>
This action cannot be undone.
</VCardText>
<VCardActions>
<VSpacer />
<VBtn @click="showDeleteDialog = false">Cancel</VBtn>
<VBtn
color="error"
:loading="isDeleting"
@click="handleDelete"
>
Delete
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>
<script lang="ts">
function getStatusColor(status: string): string {
const colors: Record<string, string> = {
draft: 'secondary',
pending: 'warning',
confirmed: 'success',
completed: 'info',
cancelled: 'error',
}
return colors[status] ?? 'secondary'
}
</script>
```
### Router Configuration
```typescript
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
component: () => import('@/layouts/default.vue'),
children: [
{
path: '',
name: 'dashboard',
component: () => import('@/pages/dashboard.vue'),
meta: { requiresAuth: true },
},
{
path: 'events',
name: 'events',
component: () => import('@/pages/events/index.vue'),
meta: { requiresAuth: true },
},
{
path: 'events/create',
name: 'events-create',
component: () => import('@/pages/events/create.vue'),
meta: { requiresAuth: true },
},
{
path: 'events/:id',
name: 'events-show',
component: () => import('@/pages/events/[id].vue'),
meta: { requiresAuth: true },
},
{
path: 'events/:id/edit',
name: 'events-edit',
component: () => import('@/pages/events/[id]/edit.vue'),
meta: { requiresAuth: true },
},
// Add more routes...
],
},
{
path: '/login',
name: 'login',
component: () => import('@/pages/login.vue'),
meta: { layout: 'blank', requiresAuth: false },
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('@/pages/[...error].vue'),
},
],
})
// Navigation guard
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore()
// Check if route requires auth
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
// Try to fetch user if we have a token
if (authStore.token) {
const success = await authStore.fetchUser()
if (success) {
return next()
}
}
return next({ name: 'login', query: { redirect: to.fullPath } })
}
// Redirect to dashboard if logged in and going to login
if (to.name === 'login' && authStore.isAuthenticated) {
return next({ name: 'dashboard' })
}
next()
})
export default router
```
### Navigation Menu
```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: 'Management',
},
{
title: 'Events',
to: { name: 'events' },
icon: { icon: 'tabler-calendar-event' },
},
{
title: 'Members',
to: { name: 'members' },
icon: { icon: 'tabler-users' },
},
{
title: 'Music',
to: { name: 'music' },
icon: { icon: 'tabler-music' },
},
{
title: 'Setlists',
to: { name: 'setlists' },
icon: { icon: 'tabler-playlist' },
},
{
heading: 'CRM',
},
{
title: 'Locations',
to: { name: 'locations' },
icon: { icon: 'tabler-map-pin' },
},
{
title: 'Customers',
to: { name: 'customers' },
icon: { icon: 'tabler-building' },
},
] as VerticalNavItems
```
## Best Practices
### Always Use
- `<script setup lang="ts">` for components
- Typed refs: `ref<string>('')`
- Vue Query for API calls
- Computed properties for derived state
- Type imports: `import type { ... }`
- Vuexy components (VBtn, VCard, VTable, etc.)
### Avoid
- Options API
- `any` types
- Raw axios calls (use composables)
- Inline styles (use Vuexy classes)
- Direct DOM manipulation
- Mutating props
## Forms with Validation
Use Vee-Validate with Zod for form validation:
```vue
<script setup lang="ts">
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'
const schema = toTypedSchema(
z.object({
title: z.string().min(1, 'Title is required'),
event_date: z.string().min(1, 'Date is required'),
start_time: z.string().min(1, 'Start time is required'),
location_id: z.string().optional(),
fee: z.number().optional(),
})
)
const { handleSubmit, errors, defineField } = useForm({
validationSchema: schema,
})
const [title, titleAttrs] = defineField('title')
const [eventDate, eventDateAttrs] = defineField('event_date')
const onSubmit = handleSubmit((values) => {
console.log('Form values:', values)
// Call API
})
</script>
<template>
<form @submit="onSubmit">
<VTextField
v-model="title"
v-bind="titleAttrs"
label="Title"
:error-messages="errors.title"
/>
<VTextField
v-model="eventDate"
v-bind="eventDateAttrs"
label="Date"
type="date"
:error-messages="errors.event_date"
/>
<VBtn type="submit" color="primary">Save</VBtn>
</form>
</template>
```
## Event Handlers
Name handlers with `handle` prefix:
```vue
<script setup lang="ts">
// ✅ Good - descriptive handler names
function handleSubmit(e: Event) {
e.preventDefault()
createEvent(formData)
}
function handleEventClick(event: Event) {
selectedEvent.value = event
showDialog.value = true
}
function handleDeleteConfirm() {
deleteEvent(eventToDelete.value)
showDeleteDialog.value = false
}
// ✅ Good - simple inline for one-liners
// <VBtn @click="showDialog = true">Open</VBtn>
// ❌ Avoid - complex logic inline
// <VBtn @click="() => { doThing(); doAnother(); updateState() }">
</script>
```
## File Organization
```
src/
├── @core/ # Vuexy core (DON'T MODIFY)
├── @layouts/ # Vuexy layouts (DON'T MODIFY)
├── assets/ # Static assets
│ ├── images/
│ └── styles/
├── components/ # Shared components
│ ├── ui/ # Base UI components
│ └── features/ # Feature-specific components
├── composables/ # Custom composables
│ ├── useEvents.ts
│ ├── useMembers.ts
│ └── useAuth.ts
├── layouts/ # App layouts
├── lib/ # Utilities
│ ├── api-client.ts
│ ├── utils.ts
│ └── query-client.ts
├── navigation/ # Menu configuration
├── pages/ # Route pages
│ ├── dashboard.vue
│ ├── events/
│ │ ├── index.vue
│ │ ├── create.vue
│ │ └── [id]/
│ │ ├── index.vue
│ │ └── edit.vue
│ └── login.vue
├── plugins/ # Vue plugins
├── router/ # Vue Router
├── stores/ # Pinia stores
├── types/ # TypeScript types
│ └── index.ts
└── App.vue
```
## Performance
### Lazy Load Routes
```typescript
// ✅ Good - lazy load all route components
const routes = [
{
path: '/events',
component: () => import('@/pages/events/index.vue'),
},
]
```
### Use Computed for Derived State
```typescript
// ✅ Good - computed is cached
const upcomingEvents = computed(() =>
events.value.filter(e => new Date(e.event_date) > new Date())
)
// ❌ Avoid - recalculates on every render
const upcomingEvents = events.value.filter(e => new Date(e.event_date) > new Date())
```
### Prefetch on Hover
```typescript
// Prefetch event details when user hovers
function handleEventHover(id: string) {
queryClient.prefetchQuery({
queryKey: ['events', id],
queryFn: () => fetchEvent(id),
})
}
```
### Use v-once for Static Content
```vue
<!-- Content that never changes -->
<div v-once>
<h1>Welcome to Band Management</h1>
<p>Manage your band operations efficiently.</p>
</div>
```
## Error Handling
```vue
<script setup lang="ts">
const { data, error, isError, isLoading } = useEvents()
</script>
<template>
<!-- Loading state -->
<VProgressCircular v-if="isLoading" indeterminate />
<!-- Error state -->
<VAlert v-else-if="isError" type="error">
{{ error?.message ?? 'Something went wrong' }}
<VBtn @click="refetch">Retry</VBtn>
</VAlert>
<!-- Success state -->
<div v-else>
<EventCard v-for="event in data" :key="event.id" :event="event" />
</div>
</template>
```
## API Integration Pattern
Create typed API functions:
```typescript
// lib/api/events.ts
import { apiClient } from '@/lib/api-client'
import type { Event, CreateEventData, ApiResponse } from '@/types'
export const eventsApi = {
list: async (params?: { page?: number; status?: string }) => {
const { data } = await apiClient.get<ApiResponse<Event[]>>('/events', { params })
return data
},
get: async (id: string) => {
const { data } = await apiClient.get<ApiResponse<Event>>(`/events/${id}`)
return data.data
},
create: async (eventData: CreateEventData) => {
const { data } = await apiClient.post<ApiResponse<Event>>('/events', eventData)
return data.data
},
update: async (id: string, eventData: Partial<CreateEventData>) => {
const { data } = await apiClient.put<ApiResponse<Event>>(`/events/${id}`, eventData)
return data.data
},
delete: async (id: string) => {
await apiClient.delete(`/events/${id}`)
},
}
```