MemberProfileModal.vue 7.36 KB
<script setup lang="ts">
import * as z from 'zod'
import type { FormSubmitEvent } from '@nuxt/ui'
import type { Member, MemberProfileForm } from '~/types'

const props = withDefaults(defineProps<{
  mode: 'create' | 'edit'
  modelValue: boolean
  member?: Member
  canSubmit?: boolean
  permissionKey?: 'members.invite' | 'members.edit'
  permissionPage?: string
}>(), {
  member: undefined,
  canSubmit: true,
  permissionKey: undefined,
  permissionPage: '/settings/members'
})

const emit = defineEmits<{
  'update:modelValue': [value: boolean]
  'submitted': []
}>()

const { t } = useAppI18n()
const toast = useToast()
const settingsApi = useSettingsApi()
const { getDeniedReason } = usePermission()

const isSubmitting = ref(false)
const fileRef = ref<HTMLInputElement>()

const roleItems = computed(() => [
  { label: t('settings.members.role.member'), value: 'member' },
  { label: t('settings.members.role.admin'), value: 'admin' },
  { label: t('settings.members.role.customer'), value: 'customer' }
])

const buildSchema = () => z.object({
  name: z.string().min(2, t('settings.profile.validation.tooShort')),
  email: z.string().email(t('settings.profile.validation.invalidEmail')),
  username: z.string().min(2, t('settings.profile.validation.tooShort')),
  role: z.enum(['member', 'admin', 'customer']),
  avatar: z.string().optional(),
  bio: z.string().optional()
})

const schema = computed(buildSchema)

type Schema = z.output<ReturnType<typeof buildSchema>>

const state = reactive<Schema>({
  name: '',
  email: '',
  username: '',
  role: 'member',
  avatar: undefined,
  bio: undefined
})

const open = computed({
  get: () => props.modelValue,
  set: (value: boolean) => emit('update:modelValue', value)
})

const modalTitle = computed(() => {
  if (props.mode === 'create') {
    return t('settings.members.inviteModal.title')
  }

  return t('settings.members.editModal.title')
})

const modalDescription = computed(() => {
  if (props.mode === 'create') {
    return t('settings.members.inviteModal.description')
  }

  return t('settings.members.editModal.description')
})

const submitLabel = computed(() => {
  if (props.mode === 'create') {
    return t('settings.members.inviteModal.submit')
  }

  return t('settings.members.editModal.submit')
})

function resetState() {
  if (props.mode === 'edit' && props.member) {
    state.name = props.member.name
    state.email = props.member.email ?? ''
    state.username = props.member.username
    state.role = props.member.role
    state.avatar = typeof props.member.avatar.src === 'string' ? props.member.avatar.src : undefined
    state.bio = props.member.bio
    return
  }

  state.name = ''
  state.email = ''
  state.username = ''
  state.role = 'member'
  state.avatar = undefined
  state.bio = undefined
}

function onFileClick() {
  fileRef.value?.click()
}

function onFileChange(e: Event) {
  const input = e.target as HTMLInputElement

  if (!input.files?.length) {
    return
  }

  state.avatar = URL.createObjectURL(input.files[0]!)
}

function closeModal() {
  open.value = false
}

async function onSubmit(event: FormSubmitEvent<Schema>) {
  if (!props.canSubmit) {
    toast.add({
      title: t('permission.toast.title'),
      description: t(getDeniedReason(props.permissionPage, props.permissionKey)),
      icon: 'i-lucide-shield-alert',
      color: 'warning'
    })
    return
  }

  isSubmitting.value = true

  try {
    if (props.mode === 'create') {
      const result = await settingsApi.inviteMember(event.data as MemberProfileForm)

      if (!result.success) {
        toast.add({
          title: t('common.error'),
          description: result.message,
          icon: 'i-lucide-circle-alert',
          color: 'error'
        })
        return
      }

      toast.add({
        title: t('settings.profile.toastTitle'),
        description: result.message,
        icon: 'i-lucide-check',
        color: 'success'
      })

      emit('submitted')
      closeModal()
      return
    }

    if (!props.member) {
      toast.add({
        title: t('common.error'),
        description: t('common.requestFailed'),
        icon: 'i-lucide-circle-alert',
        color: 'error'
      })
      return
    }

    const result = await settingsApi.updateMemberProfile(props.member.username, event.data as MemberProfileForm)

    if (!result.success) {
      toast.add({
        title: t('common.error'),
        description: result.message,
        icon: 'i-lucide-circle-alert',
        color: 'error'
      })
      return
    }

    toast.add({
      title: t('settings.profile.toastTitle'),
      description: result.message,
      icon: 'i-lucide-check',
      color: 'success'
    })

    emit('submitted')
    closeModal()
  } catch {
    toast.add({
      title: t('common.error'),
      description: t('common.requestFailed'),
      icon: 'i-lucide-circle-alert',
      color: 'error'
    })
  } finally {
    isSubmitting.value = false
  }
}

watch(
  () => [open.value, props.member, props.mode],
  ([nextOpen]) => {
    if (nextOpen) {
      resetState()
    }
  },
  { immediate: true }
)
</script>

<template>
  <UModal v-model:open="open" :title="modalTitle" :description="modalDescription">
    <template #body>
      <UForm
        :schema="schema"
        :state="state"
        class="space-y-4"
        @submit="onSubmit"
      >
        <UFormField name="name" :label="t('settings.profile.name')" required>
          <UInput v-model="state.name" class="w-full" />
        </UFormField>

        <UFormField name="email" :label="t('settings.profile.email')" required>
          <UInput v-model="state.email" type="email" class="w-full" />
        </UFormField>

        <UFormField name="username" :label="t('settings.profile.username')" required>
          <UInput v-model="state.username" class="w-full" />
        </UFormField>

        <UFormField name="role" :label="t('settings.members.roleLabel')" required>
          <USelect v-model="state.role" :items="roleItems" class="w-full" />
        </UFormField>

        <UFormField
          name="avatar"
          :label="t('settings.profile.avatar')"
          :description="t('settings.profile.avatarDesc')"
        >
          <div class="flex flex-wrap items-center gap-3">
            <UAvatar :src="state.avatar" :alt="state.name" size="lg" />
            <UButton
              :label="t('common.choose')"
              color="neutral"
              type="button"
              @click="onFileClick"
            />
            <input
              ref="fileRef"
              type="file"
              class="hidden"
              accept=".jpg, .jpeg, .png, .gif"
              @change="onFileChange"
            >
          </div>
        </UFormField>

        <UFormField name="bio" :label="t('settings.profile.bio')" :description="t('settings.profile.bioDesc')">
          <UTextarea
            v-model="state.bio"
            :rows="4"
            autoresize
            class="w-full"
          />
        </UFormField>

        <div class="flex justify-end gap-2">
          <UButton
            :label="t('common.cancel')"
            color="neutral"
            variant="subtle"
            type="button"
            @click="closeModal"
          />
          <UButton
            :label="submitLabel"
            color="primary"
            variant="solid"
            type="submit"
            :disabled="!canSubmit"
            :loading="isSubmitting"
          />
        </div>
      </UForm>
    </template>
  </UModal>
</template>