1057 lines
25 KiB
Plaintext
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}`)
|
|
},
|
|
}
|
|
```
|