Frontend

Composables

Composable patterns and the composable library in codi-layer.

Conventions

  • All composables are factory functions named use<Domain>() — not use<Domain with Composable suffix.
  • 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/useAsyncData inside 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 inside setup, 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.