Frontend
Composables
Composable patterns and the composable library in codi-layer.
Conventions
- All composables are factory functions named
use<Domain>()— notuse<DomainwithComposablesuffix. - They live in
app/composables/and are auto-imported by Nuxt. - Shared composables live in
codi-layer/app/composables/and are available in all apps via the layer. - Do not wrap composables in
defineNuxtComposable— plain functions only.
useAuth
The central authentication composable. Manages access/refresh tokens (cookies), current user state, and socket lifecycle.
// codi-layer/app/composables/useAuth.ts
export default function useAuth() {
const { cookieConfig } = useRuntimeConfig().public
// Global shared state — key "currentUser" is the same across all components
const currentUser = useState<TUser | null>('currentUser', () => null)
const token = useCookie<string | null>('accessToken', cookieConfig)
const refreshToken = useCookie<string | null>('refreshToken', cookieConfig)
const userId = useCookie<string | null>('userId', cookieConfig)
const isLoading = ref(false)
const error = ref<string | null>(null)
async function login(email: string, password: string) {
try {
isLoading.value = true
const response = await useNuxtApp().$api<{
accessToken: string
refreshToken?: string
_id: string
}>('/api/auth/login', { method: 'POST', body: { email, password } })
token.value = response.accessToken
userId.value = response._id
currentUser.value = await getCurrentUser()
// Connect socket after login
const { $socket } = useNuxtApp()
if ($socket && currentUser.value?._id) {
$socket.connect()
$socket.joinUserRoom(currentUser.value._id)
}
return response
} catch (err: any) {
error.value = err?.data?.message || 'Login failed'
throw err
} finally {
isLoading.value = false
}
}
async function logout() {
await useNuxtApp().$api('/api/auth/logout', { method: 'DELETE' })
token.value = null
refreshToken.value = null
userId.value = null
currentUser.value = null
await navigateTo('/login')
}
async function getCurrentUser() {
return useNuxtApp().$api<TUser>('/api/auth/user', { method: 'GET' })
}
return { currentUser, token, isLoading, error, login, logout, getCurrentUser }
}
useTenant
Detects the current tenant from the subdomain. Used only in customer-booki-web-app.
// codi-layer/app/composables/useTenant.ts
export default function useTenant() {
const requestURL = useRequestURL()
const tenantState = useState<{ slug: string } | null>('tenant', () => {
const host = requestURL.hostname // e.g. "myshop.booki.com"
const parts = host.split('.')
let slug: string | null = null
if (parts.length >= 3) slug = parts[0] ?? null
// local dev: "myshop.localhost" → slug = "myshop"
else if (parts.length === 2 && parts[1] === 'localhost') slug = parts[0] ?? null
return slug ? { slug } : null
})
return tenantState
}
The slug is injected as an x-tenant-slug header by the $api plugin on every request.
useFormHandler
A reusable form helper that provides built-in validation rules and a standardised API action wrapper with toast feedback.
// codi-layer/app/composables/useFormHandler.ts
export default function useFormHandler() {
const loading = ref(false)
const { alertRef } = useUtils()
const commonValidationRules: TValidationRules = {
required: (v) => !v || v.trim() === '' ? 'This field is required.' : '',
email: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? '' : 'Invalid email.',
password: (v) => {
if (!v) return 'Password is required.'
if (v.length < 8) return 'Must be at least 8 characters.'
if (!/[A-Z]/.test(v)) return 'Must contain an uppercase letter.'
if (!/[0-9]/.test(v)) return 'Must contain a number.'
if (!/[!@#$%^&*]/.test(v)) return 'Must contain a special character.'
return ''
},
}
// Wraps any async action with loading state + toast feedback
const handleApiAction = async <T>(
action: () => Promise<T>,
options: TApiActionOptions = {},
): Promise<T | null> => {
const { successMessage, errorMessage = 'An error occurred.', onSuccess, onError, skipAlert } = options
loading.value = true
try {
const result = await action()
if (onSuccess) await onSuccess(result)
if (!skipAlert) alertRef?.value?.triggerAlert(successMessage || 'Success.', 'success')
return result
} catch (error: any) {
const msg = error?.data?.message || error?.message || errorMessage
if (onError) onError({ ...error, message: msg })
if (!skipAlert) alertRef?.value?.triggerAlert(msg, 'error')
throw { ...error, message: msg }
} finally {
loading.value = false
}
}
// Field-by-field validation — mutates the errors reactive object
const validateFields = (
formData: Record<string, any>,
rules: Record<string, TValidationRule | string>,
errors: Record<string, string>,
): boolean => {
let isValid = true
Object.keys(errors).forEach((k) => (errors[k] = ''))
Object.entries(rules).forEach(([field, ruleKey]) => {
const rule = typeof ruleKey === 'string' ? commonValidationRules[ruleKey] : ruleKey
const err = rule?.(formData[field])
if (err) { errors[field] = err; isValid = false }
})
return isValid
}
return { loading, handleApiAction, validateFields, commonValidationRules }
}
Usage in a page:
const { loading, handleApiAction, validateFields } = useFormHandler()
const form = reactive({ email: '', password: '' })
const errors = reactive({ email: '', password: '' })
async function submit() {
const valid = validateFields(form, { email: 'email', password: 'password' }, errors)
if (!valid) return
await handleApiAction(
() => useAuth().login(form.email, form.password),
{ successMessage: 'Welcome back!', onSuccess: () => navigateTo('/dashboard') },
)
}
useBooking
API call wrappers for the bookings resource plus reusable reactive state factories.
// codi-layer/app/composables/useBooking.ts
export default function useBooking() {
const { page, pages, pageRange, items } = usePage()
function getBookings({ page = 1, limit = 10, order = 'asc', sort = 'createdAt' } = {}) {
return useNuxtApp().$api<Record<string, any>>('/api/bookings', {
method: 'GET',
query: { page, limit, order, sort },
})
}
function createBookingAuthenticated(payload: {
packageId: string
bookingDate: string
bookingTime: string
}) {
return useNuxtApp().$api<Record<string, any>>('/api/bookings', {
method: 'POST',
body: payload,
})
}
function cancelBooking(id: string, reason: string) {
return useNuxtApp().$api(`/api/bookings/${id}/cancel`, {
method: 'PATCH',
body: { reason },
})
}
// Factories for per-page state — call inside setup, not at module level
function createDialogState() {
return reactive({ view: false, reschedule: false, cancel: false })
}
function createFormStates() {
return {
loading: ref(false),
cancelReason: ref(''),
cancelReasonError: ref(''),
reschedule: reactive({
selectedDate: new Date(),
time: '',
period: 'AM' as TPeriod,
}),
}
}
return { page, pages, pageRange, items, getBookings, createBookingAuthenticated, cancelBooking, createDialogState, createFormStates }
}
usePage
Lightweight pagination primitive. Keeps page, total pages, display range, and current items.
export default function usePage() {
const page = ref(1)
const pages = ref(0)
const pageRange = ref('-- - -- of --')
const items = ref<Array<Record<string, any>>>([])
const search = ref('')
return { page, pages, pageRange, items, search }
}
usePayment
export default function usePayment() {
const isPaymentProcessing = ref(false)
const paymentError = ref<string | null>(null)
function createPayment(bookingId: string, payload: TCreatePayment) {
return useNuxtApp().$api<Record<string, any>>(
`/api/bookings/${bookingId}/payment`,
{ method: 'POST', body: payload },
)
}
function connectPayment(type: TPaymentType, body?: Record<string, any>) {
return useNuxtApp().$api<Record<string, any>>(
`/api/organizations/payments/${type}`,
{ method: 'POST', body: body || {} },
)
}
return { isPaymentProcessing, paymentError, createPayment, connectPayment }
}
Guidelines
- Return plain Promises from API call wrappers — don't use
useFetch/useAsyncDatainside composables. Let pages decide how to handle async. - Never hard-code API paths — always use the
/api/proxy prefix. - Factories for per-component state (
createDialogState,createFormStates) — call them insidesetup, not at the composable module level, to avoid shared mutable state across components. - Reactive state shared across components → use
useState('key', ...)with a consistent key string.