components/profile/create/Modal.vue
<template>
<NeoModal
:value="vOpen"
@close="close"
>
<ModalBody
:title="'Profile Creation'"
:content-class="stage === 1 ? 'p-0' : undefined"
@close="close"
>
<Introduction
v-if="stage === 1"
@next="stage = 2"
@close="close"
/>
<Select
v-if="stage === 2"
:loading="farcasterSignInIsInProgress"
@start-new="OnSelectStartNew"
@import-farcaster="onSelectFarcaster"
/>
<Form
v-if="stage === 3"
:farcaster-user-data="farcasterUserData"
:use-farcaster="useFarcaster"
:signing-message="signingMessage"
@submit="handleFormSubmition"
@delete="handleProfileDelete"
/>
</ModalBody>
</NeoModal>
</template>
<script setup lang="ts">
import { NeoModal } from '@kodadot1/brick'
import type { StatusAPIResponse } from '@farcaster/auth-client'
import { useDocumentVisibility } from '@vueuse/core'
import type { ProfileFormData } from './stages/index'
import { Form, Introduction, Select } from './stages/index'
import { deleteProfile } from '@/services/profile'
import { appClient, createChannel } from '@/services/farcaster'
import type { NotificationAction } from '@/utils/notification'
type SessionState = {
state: LoadingNotificationState
error?: Error
}
const emit = defineEmits(['close', 'success', 'deleted'])
const props = defineProps<{
skipIntro?: boolean
}>()
const { urlPrefix } = usePrefix()
const { accountId } = useAuth()
const { $i18n } = useNuxtApp()
const { getSignaturePair } = useVerifyAccount()
const documentVisibility = useDocumentVisibility()
const { add: generateSession, get: getSession } = useIdMap<Ref<SessionState>>()
const { params } = useRoute()
const { fetchProfile } = useProfile(computed(() => params?.id as string))
const { hasProfile, userProfile } = useProfile()
provide('userProfile', { hasProfile, userProfile })
const initialStep = computed(() => (props.skipIntro || hasProfile.value ? 2 : 1))
const signingMessage = ref(false)
const vOpen = ref(true)
const stage = ref(initialStep.value)
const farcasterUserData = ref<StatusAPIResponse>()
const useFarcaster = ref(false)
const farcasterSignInIsInProgress = ref(false)
const close = () => {
vOpen.value = false
emit('close')
}
const handleProfileDelete = async (address: string) => {
try {
const { signature, message } = await getSignaturePair()
await deleteProfile({ address, message, signature })
infoMessage($i18n.t('profiles.profileHasBeenCleared'), {
title: $i18n.t('profiles.profileReset'),
})
emit('deleted')
fetchProfile()
close()
}
catch (error) {
warningMessage(error!.toString())
console.error(error)
}
}
const handleFormSubmition = async (profileData: ProfileFormData) => {
let signaturePair: undefined | SignaturePair
try {
signingMessage.value = true
signaturePair = await getSignaturePair()
signingMessage.value = false
close()
onModalAnimation(() => {
stage.value = 4 // Go to loading stage
})
}
catch (error) {
stage.value = 3 // Back to form stage
reset()
warningMessage(error!.toString())
console.error(error)
}
if (!signaturePair) {
return
}
const sessionId = generateSession(
ref({
state: 'loading',
}),
)
const session = getSession(sessionId)
if (!session) {
return
}
// using a seperate try catch to show errors using the profile creation notification
try {
showProfileCreationNotification(session)
await useUpdateProfile({
profileData,
signaturePair,
hasProfile: hasProfile.value,
useFarcaster: useFarcaster.value,
})
profileCreated(sessionId)
}
catch (error) {
profileCreationFailed(sessionId, error as Error)
}
}
const showProfileCreationNotification = (session: Ref<SessionState>) => {
const isSessionState = (state: LoadingNotificationState) =>
session.value?.state === state
loadingMessage({
title: computed(() =>
isSessionState('failed')
? $i18n.t('profiles.errors.setupFailed.title')
: $i18n.t('profiles.created'),
),
message: computed(() =>
isSessionState('failed')
? $i18n.t('profiles.errors.setupFailed.message')
: undefined,
),
state: computed(() => session?.value.state as LoadingNotificationState),
action: computed<NotificationAction | undefined>(() => {
if (isSessionState('failed')) {
return getReportIssueAction(session?.value?.error?.toString() as string)
}
if (isSessionState('succeeded')) {
return {
label: $i18n.t('viewProfile'),
icon: 'arrow-up-right',
url: `/${urlPrefix.value}/u/${accountId.value}`,
}
}
return undefined
}),
})
}
const profileCreated = (sessionId: string) => {
emit('success')
fetchProfile()
stage.value = 5 // Go to success stage
updateSession(sessionId, { state: 'succeeded' })
}
const reset = () => {
signingMessage.value = false
}
const profileCreationFailed = (sessionId: string, error: Error) => {
reset()
console.error(error)
updateSession(sessionId, { state: 'failed', error: error })
}
const onSelectFarcaster = () => {
if (farcasterUserData.value) {
stage.value = 3
useFarcaster.value = true
}
else {
farcasterSignInIsInProgress.value = true
loginWithFarcaster()
.then(() => {
farcasterSignInIsInProgress.value = false
stage.value = 3
useFarcaster.value = true
})
.catch((error) => {
farcasterSignInIsInProgress.value = false
console.error(error)
dangerMessage(
$i18n.t('profiles.errors.unsuccessfulFarcasterAuth.message'),
{
title: $i18n.t('profiles.errors.unsuccessfulFarcasterAuth.title'),
reportable: false,
},
)
})
}
}
const OnSelectStartNew = () => {
stage.value = 3
useFarcaster.value = false
}
const loginWithFarcaster = async () => {
const channel = await createChannel()
if (!channel?.data?.url) {
throw new Error('[PROFILES::FARCASTER_AUTH] URL not found in channel data')
}
// Open a new tab with the URL
const farcasterTab = window.open(channel.data.url, '_blank')
const userData = await appClient.watchStatus({
channelToken: channel.data.channelToken,
timeout: 60_000,
interval: 1_000,
})
farcasterTab?.close()
const stateNotCompleted = userData?.data?.state !== 'completed'
const nonceMismatch = userData.data?.nonce !== channel.data?.nonce
if (stateNotCompleted || nonceMismatch) {
throw new Error(
`[PROFILES::FARCASTER_AUTH] ${stateNotCompleted ? 'No user data found' : 'Nonce mismatch'}`,
)
}
farcasterUserData.value = userData.data
}
const updateSession = (id: string, newSession: SessionState) => {
const session = getSession(id)
if (!session) {
return
}
session.value = newSession
}
useModalIsOpenTracker({
isOpen: vOpen,
onClose: false,
onChange: () => {
stage.value = initialStep.value
},
})
watch(documentVisibility, (current, previous) => {
if (
current === 'visible'
&& previous === 'hidden'
&& farcasterSignInIsInProgress.value
) {
infoMessage($i18n.t('profiles.errors.unconfrimedFarcasterAuth.message'), {
title: $i18n.t('profiles.errors.unconfrimedFarcasterAuth.title'),
})
}
})
</script>