diff --git a/components/common/Notification.vue b/components/common/Notification.vue index ac0e475cd0..39244abef5 100644 --- a/components/common/Notification.vue +++ b/components/common/Notification.vue @@ -3,11 +3,16 @@ :duration="duration" :title="title" :variant="variant" + :icon="icon" + :hold-timer="holdTimer" auto-close show-progress-bar @close="emit('close')" > -
+

{{ message }}

@@ -15,34 +20,57 @@ {{ action.label }} +
diff --git a/components/profile/create/Modal.vue b/components/profile/create/Modal.vue index 8676f6b47c..0530153a8d 100644 --- a/components/profile/create/Modal.vue +++ b/components/profile/create/Modal.vue @@ -23,14 +23,10 @@ v-if="stage === 3" :farcaster-user-data="farcasterUserData" :use-farcaster="useFarcaster" + :signing-message="signingMessage" @submit="handleFormSubmition" @delete="handleProfileDelete" /> - - @@ -39,125 +35,46 @@ import { NeoModal } from '@kodadot1/brick' import type { StatusAPIResponse } from '@farcaster/auth-client' import { useDocumentVisibility } from '@vueuse/core' -import { getBioWithLinks } from '../utils' -import type { - ProfileFormData } from './stages/index' -import { - Form, - Introduction, - Loading, - Select, - Success, -} from './stages/index' -import type { - CreateProfileRequest, - SocialLink, - UpdateProfileRequest } from '@/services/profile' -import { - createProfile, - deleteProfile, - updateProfile, - uploadImage, -} from '@/services/profile' +import type { ProfileFormData } from './stages/index' +import { Form, Introduction, Select } from './stages/index' +import { deleteProfile } from '@/services/profile' import { appClient, createChannel } from '@/services/farcaster' +type SessionState = { + state: LoadingNotificationState + error?: Error +} + const emit = defineEmits(['close', 'success', 'deleted']) const props = defineProps<{ skipIntro?: boolean }>() -const documentVisibility = useDocumentVisibility() -const { $i18n } = useNuxtApp() +const { urlPrefix } = usePrefix() const { accountId } = useAuth() +const { $i18n } = useNuxtApp() +const { getSignaturePair } = useVerifyAccount() +const documentVisibility = useDocumentVisibility() +const { add: generateSession, get: getSession } = useIdMap>() const { fetchProfile } = useProfile() const { hasProfile, userProfile } = useProfile() - provide('userProfile', { hasProfile, userProfile }) const initialStep = computed(() => (props.skipIntro || hasProfile.value ? 2 : 1)) -const { getSignaturePair } = useVerifyAccount() +const signingMessage = ref(false) const vOpen = ref(true) const stage = ref(initialStep.value) const farcasterUserData = ref() const useFarcaster = ref(false) const farcasterSignInIsInProgress = ref(false) + const close = () => { vOpen.value = false emit('close') } -const uploadProfileImage = async ( - file: File | null, - type: 'image' | 'banner', -): Promise => { - if (!file) { - return undefined - } - - const { signature, message } = await getSignaturePair() - - const response = await uploadImage({ - file, - type, - address: accountId.value, - signature, - message, - }) - - return response.url -} - -const constructSocials = (profileData: ProfileFormData): SocialLink[] => { - return [ - { - handle: profileData.farcasterHandle || '', - platform: 'Farcaster', - link: `https://warpcast.com/${profileData.farcasterHandle}`, - }, - { - handle: profileData.twitterHandle || '', - platform: 'Twitter', - link: `https://twitter.com/${profileData.twitterHandle}`, - }, - { - handle: profileData.website || '', - platform: 'Website', - link: profileData.website || '', - }, - ].filter(social => Boolean(social.handle)) -} - -const processProfile = async (profileData: ProfileFormData) => { - const { signature, message } = await getSignaturePair() - - const imageUrl = profileData.image - ? await uploadProfileImage(profileData.image, 'image') - : profileData.imagePreview - - const bannerUrl = profileData.banner - ? await uploadProfileImage(profileData.banner, 'banner') - : profileData.bannerPreview - - const profileBody: CreateProfileRequest | UpdateProfileRequest = { - address: profileData.address, - name: profileData.name, - description: useFarcaster.value - ? getBioWithLinks(profileData.description) - : profileData.description, - image: imageUrl, - banner: hasProfile.value ? bannerUrl ?? null : bannerUrl!, - socials: constructSocials(profileData), - signature, - message, - } - - return hasProfile.value - ? updateProfile(profileBody as UpdateProfileRequest) - : createProfile(profileBody as CreateProfileRequest) -} - const handleProfileDelete = async (address: string) => { try { const { signature, message } = await getSignaturePair() @@ -176,19 +93,106 @@ const handleProfileDelete = async (address: string) => { } const handleFormSubmition = async (profileData: ProfileFormData) => { - stage.value = 4 // Go to loading stage - try { - await processProfile(profileData) - emit('success') + let signaturePair: undefined | SignaturePair - fetchProfile() - stage.value = 5 // Go to success stage + 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) => { + 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(() => { + 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 = () => { @@ -252,8 +256,18 @@ const loginWithFarcaster = async () => { farcasterUserData.value = userData.data } +const updateSession = (id: string, newSession: SessionState) => { + const session = getSession(id) + + if (!session) { + return + } + + session.value = newSession +} + useModalIsOpenTracker({ - isOpen: computed(() => props.modelValue), + isOpen: vOpen, onClose: false, onChange: () => { stage.value = initialStep.value diff --git a/components/profile/create/stages/Form.vue b/components/profile/create/stages/Form.vue index f69804fa44..68889cf8b4 100644 --- a/components/profile/create/stages/Form.vue +++ b/components/profile/create/stages/Form.vue @@ -131,7 +131,11 @@ () const deleteConfirm = ref() @@ -272,7 +277,11 @@ const form = reactive({ const userProfile = computed(() => profile?.userProfile.value) const missingImage = computed(() => (form.imagePreview ? false : !form.image)) const submitDisabled = computed( - () => !form.name || !form.description || missingImage.value, + () => + !form.name + || !form.description + || missingImage.value + || props.signingMessage, ) const validatingFormInput = (model: string) => { diff --git a/components/profile/create/stages/Loading.vue b/components/profile/create/stages/Loading.vue deleted file mode 100644 index d3ef6d2917..0000000000 --- a/components/profile/create/stages/Loading.vue +++ /dev/null @@ -1,18 +0,0 @@ - - - diff --git a/components/profile/create/stages/Success.vue b/components/profile/create/stages/Success.vue deleted file mode 100644 index ee535136d8..0000000000 --- a/components/profile/create/stages/Success.vue +++ /dev/null @@ -1,54 +0,0 @@ - - - diff --git a/components/profile/create/stages/index.ts b/components/profile/create/stages/index.ts index 9a3c35ac9d..59a3077ef7 100644 --- a/components/profile/create/stages/index.ts +++ b/components/profile/create/stages/index.ts @@ -1,8 +1,6 @@ export { default as Introduction } from './Introduction.vue' export { default as Select } from './Select.vue' export { default as Form } from './Form.vue' -export { default as Loading } from './Loading.vue' -export { default as Success } from './Success.vue' export type ProfileFormData = { address: string diff --git a/components/profile/create/utils.ts b/components/profile/create/utils.ts new file mode 100644 index 0000000000..b2e0c9d777 --- /dev/null +++ b/components/profile/create/utils.ts @@ -0,0 +1,51 @@ +import type { ProfileFormData } from './stages' +import type { SocialLink } from '@/services/profile' +import { uploadImage } from '@/services/profile' + +export const constructSocials = ( + profileData: ProfileFormData, +): SocialLink[] => { + return [ + { + handle: profileData.farcasterHandle || '', + platform: 'Farcaster', + link: `https://warpcast.com/${profileData.farcasterHandle}`, + }, + { + handle: profileData.twitterHandle || '', + platform: 'Twitter', + link: `https://twitter.com/${profileData.twitterHandle}`, + }, + { + handle: profileData.website || '', + platform: 'Website', + link: profileData.website || '', + }, + ].filter(social => Boolean(social.handle)) +} + +export const uploadProfileImage = async ({ + file, + type, + signaturePair: { signature, message }, +}: { + file: File | null + type: 'image' | 'banner' + signaturePair: SignaturePair +}): Promise => { + if (!file) { + return undefined + } + + const { accountId } = useAuth() + + const response = await uploadImage({ + file, + type, + address: accountId.value, + signature, + message, + }) + + return response.url +} diff --git a/components/shared/DynamicGrid.vue b/components/shared/DynamicGrid.vue index 3add1ba5af..c3bdd404a0 100644 --- a/components/shared/DynamicGrid.vue +++ b/components/shared/DynamicGrid.vue @@ -2,6 +2,7 @@
() { + const map = ref(new Map()) + + const add = (initialValue: T) => { + const id = window.crypto.randomUUID() + map.value.set(id, initialValue as any) + return id + } + + const get = (id: string): T | undefined => map.value.get(id) as T | undefined + + return { + add, + get, + } +} diff --git a/composables/useModalIsOpenTracker.ts b/composables/useModalIsOpenTracker.ts index e1189e5754..26842cd4b1 100644 --- a/composables/useModalIsOpenTracker.ts +++ b/composables/useModalIsOpenTracker.ts @@ -2,6 +2,9 @@ import { debounce } from 'lodash' export const NEO_MODAL_ANIMATION_DURATION = 200 +export const onModalAnimation = onChange => + debounce(onChange, NEO_MODAL_ANIMATION_DURATION)() + export default ({ onChange, isOpen, @@ -15,7 +18,7 @@ export default ({ }) => { watch([isOpen, () => and], ([isOpen, and]) => { if (!isOpen === onClose && and.every(Boolean)) { - ;(onClose ? debounce(onChange, NEO_MODAL_ANIMATION_DURATION) : onChange)() + ;(onClose ? onModalAnimation(onChange) : onChange)() } }) } diff --git a/composables/useUpdateProfile.ts b/composables/useUpdateProfile.ts new file mode 100644 index 0000000000..aa636d34e1 --- /dev/null +++ b/composables/useUpdateProfile.ts @@ -0,0 +1,58 @@ +import type { + CreateProfileRequest, + UpdateProfileRequest } from '@/services/profile' +import { + createProfile, + updateProfile, +} from '@/services/profile' +import { getBioWithLinks } from '@/components/profile/utils' +import { + constructSocials, + uploadProfileImage, +} from '@/components/profile/create/utils' +import type { ProfileFormData } from '@/components/profile/create/stages' + +export default async ({ + profileData, + signaturePair, + hasProfile, + useFarcaster, +}: { + profileData: ProfileFormData + signaturePair: SignaturePair + hasProfile: boolean + useFarcaster: boolean +}) => { + const imageUrl = profileData.image + ? await uploadProfileImage({ + file: profileData.image, + type: 'image', + signaturePair, + }) + : profileData.imagePreview + + const bannerUrl = profileData.banner + ? await uploadProfileImage({ + file: profileData.banner, + type: 'banner', + signaturePair, + }) + : profileData.bannerPreview + + const profileBody: CreateProfileRequest | UpdateProfileRequest = { + address: profileData.address, + name: profileData.name, + description: useFarcaster + ? getBioWithLinks(profileData.description) + : profileData.description, + image: imageUrl, + banner: hasProfile ? (bannerUrl ?? null) : bannerUrl!, + socials: constructSocials(profileData), + signature: signaturePair.signature, + message: signaturePair.message, + } + + return hasProfile + ? updateProfile(profileBody as UpdateProfileRequest) + : createProfile(profileBody as CreateProfileRequest) +} diff --git a/composables/useVerifyAccount.ts b/composables/useVerifyAccount.ts index bdd6e58817..726f23b93c 100644 --- a/composables/useVerifyAccount.ts +++ b/composables/useVerifyAccount.ts @@ -1,6 +1,8 @@ import { isEthereumAddress } from '@polkadot/util-crypto' import { signMessage as signMessageEvm } from '@wagmi/core' +export type SignaturePair = { signature: string, message: string } + const signMessagePolkadot = async (address: string, message: string) => { const injector = await getAddress(toDefaultAddress(address)) const signedMessage = await injector.signer.signRaw({ @@ -51,7 +53,7 @@ export default function useVerifyAccount() { throw new Error('You have not completed address verification') } - const getSignaturePair = async () => { + const getSignaturePair = async (): Promise => { const signature = await getSignedMessage() return { signature, diff --git a/libs/ui/src/components/NeoIcon/NeoIcon.vue b/libs/ui/src/components/NeoIcon/NeoIcon.vue index c88b67d713..ed04d67102 100644 --- a/libs/ui/src/components/NeoIcon/NeoIcon.vue +++ b/libs/ui/src/components/NeoIcon/NeoIcon.vue @@ -5,6 +5,7 @@ :size="size || 'default'" :custom-size="customSize" :variant="variant" + :spin="spin" /> @@ -17,6 +18,7 @@ defineProps<{ pack?: string customSize?: string variant?: 'success' | 'primary' | 'k-grey' + spin?: boolean }>() diff --git a/libs/ui/src/components/NeoMessage/NeoMessage.scss b/libs/ui/src/components/NeoMessage/NeoMessage.scss index 5cd2d7f36b..1a72ac7c9b 100644 --- a/libs/ui/src/components/NeoMessage/NeoMessage.scss +++ b/libs/ui/src/components/NeoMessage/NeoMessage.scss @@ -1,6 +1,17 @@ .message { @apply bg-[whitesmoke] rounded-none text-base; + &__success { + @apply bg-k-green-light text-k-green; + + .message-progress { + @apply bg-k-green; + } + } + + &__neutral { + @apply bg-background-color text-k-grey; + } &__danger { @apply bg-k-red-accent-2 text-k-red; diff --git a/libs/ui/src/components/NeoMessage/NeoMessage.vue b/libs/ui/src/components/NeoMessage/NeoMessage.vue index c277b7d7c4..aef1c3d8fa 100644 --- a/libs/ui/src/components/NeoMessage/NeoMessage.vue +++ b/libs/ui/src/components/NeoMessage/NeoMessage.vue @@ -3,14 +3,15 @@
@@ -53,13 +54,18 @@ import { useElementHover } from '@vueuse/core' import NeoButton from '../NeoButton/NeoButton.vue' import NeoIcon from '../NeoIcon/NeoIcon.vue' -import type { NeoMessageVariant } from '../../types' +import type { + NeoMessageCustomIconVariant, + NeoMessageIconVariant, + NeoMessageVariant, +} from '../../types' -const iconVariant: Record = { +const iconVariant: Record = { info: 'circle-info', - success: 'check-circle', + success: 'check', warning: 'circle-exclamation', danger: 'circle-exclamation', + neutral: 'circle-info', } const emit = defineEmits(['close', 'update:active', 'click']) @@ -72,6 +78,8 @@ const props = withDefaults( autoClose: boolean duration: number showProgressBar: boolean + icon?: NeoMessageIconVariant + holdTimer?: boolean }>(), { active: true, @@ -81,6 +89,8 @@ const props = withDefaults( showProgressBar: false, variant: 'success', title: '', + icon: undefined, + holdTimer: false, }, ) @@ -91,7 +101,17 @@ const timer = ref() const isActive = ref(props.active) const remainingTime = ref(props.duration) -const computedIcon = computed(() => iconVariant[props.variant] ?? null) +const computedIcon = computed( + () => props.icon ?? iconVariant[props.variant] ?? null, +) +const iconName = computed( + () => + (computedIcon.value as NeoMessageCustomIconVariant)?.icon + ?? computedIcon.value, +) +const iconSpin = computed( + () => (computedIcon.value as NeoMessageCustomIconVariant).spin, +) const percent = computed(() => { return (remainingTime.value / props.duration) * 100 @@ -104,8 +124,8 @@ const close = () => { emit('update:active', false) } -watch(isActive, (active) => { - if (active) { +watch([isActive, () => props.holdTimer], ([active, holdTimer]) => { + if (active && !holdTimer) { startTimer() } else if (timer.value) { @@ -136,7 +156,7 @@ watch( active => (isActive.value = active), ) -onMounted(startTimer) +onMounted(() => !props.holdTimer && startTimer()) onUnmounted(() => clearTimeout(timer.value)) diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index abee52a8f5..d17b65d449 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -5,8 +5,11 @@ export { default as NeoDropdownItem } from './components/NeoDropdown/NeoDropdown export { default as NeoSelect } from './components/NeoSelect/NeoSelect.vue' export { default as NeoSidebar } from './components/NeoSidebar/NeoSidebar.vue' export { default as NeoCheckbox } from './components/NeoCheckbox/NeoCheckbox.vue' -export { type NeoButtonVariant } from './types' -export { type NeoMessageVariant } from './types' +export type { + NeoButtonVariant, + NeoMessageVariant, + NeoMessageIconVariant, +} from './types' export { default as TheButton } from './components/TheButton/TheButton.vue' export { default as NeoTooltip } from './components/NeoTooltip/NeoTooltip.vue' diff --git a/libs/ui/src/scss/tailwind.scss b/libs/ui/src/scss/tailwind.scss index 322362b25c..5b9acc702f 100644 --- a/libs/ui/src/scss/tailwind.scss +++ b/libs/ui/src/scss/tailwind.scss @@ -27,6 +27,7 @@ --k-accent-light-2-dark-head: #191718; --k-accent-light-2-dark-paragraph: #191718; --k-green: #04af00; + --k-green-light: #f3fbf3; --k-red: #ff5757; --k-orange: #cf9a10; --k-orange-light: #FFD379; @@ -99,6 +100,7 @@ --k-yellow-light: #3F3500; --k-blue-accent: #2e50a2; --k-aqua-blue: #106153; + --k-green-light: #0a3009; --k-green-accent: #056a02; --k-green-accent-2: #0a3009; --k-blue-light: #363234; diff --git a/libs/ui/src/types.ts b/libs/ui/src/types.ts index b98307f525..25521d31f9 100644 --- a/libs/ui/src/types.ts +++ b/libs/ui/src/types.ts @@ -15,4 +15,12 @@ export type NeoButtonVariant = | 'pill' | 'border-icon' -export type NeoMessageVariant = 'warning' | 'success' | 'danger' | 'info' +export type NeoMessageVariant = + | 'warning' + | 'success' + | 'danger' + | 'info' + | 'neutral' + +export type NeoMessageCustomIconVariant = { icon: string, spin: boolean } +export type NeoMessageIconVariant = string | NeoMessageCustomIconVariant diff --git a/libs/ui/tailwind.config.js b/libs/ui/tailwind.config.js index 8798fdfc58..c5759cc8f1 100644 --- a/libs/ui/tailwind.config.js +++ b/libs/ui/tailwind.config.js @@ -27,6 +27,7 @@ module.exports = { 'var(--k-accent-light-2-dark-paragraph)', 'k-accent-light-3': 'var(--k-accent-light-3)', 'k-green': 'var(--k-green)', + 'k-green-light': 'var(--k-green-light)', 'k-red': 'var(--k-red)', 'k-orange': 'var(--k-orange)', 'k-orange-light': 'var(--k-orange-light)', diff --git a/locales/en.json b/locales/en.json index 1024f78bff..d2c4834811 100644 --- a/locales/en.json +++ b/locales/en.json @@ -2075,6 +2075,10 @@ "unsuccessfulFarcasterAuth": { "title": "Unsuccessful Connection", "message": "Somehting went wrong linking with your farcaster account, please try again." + }, + "setupFailed": { + "title": "Profile Setup Failed", + "message": "Error occurred. Try again later or report issue." } }, @@ -2087,7 +2091,9 @@ "deleteConfirm": "You sure? - click again", "waitSeconds": "Wait {0} Seconds", "profileReset": "Profile Reset", - "profileHasBeenCleared": "Your profile has been cleared successfully. Start fresh!" + "profileHasBeenCleared": "Your profile has been cleared successfully. Start fresh!", + "finishCustomization": "Finish Customization", + "created": "Profile Created" }, "reconnect": { "required": "Reconnect Required", diff --git a/utils/notification.ts b/utils/notification.ts index 22e8c5ae26..9b1d2da5cf 100644 --- a/utils/notification.ts +++ b/utils/notification.ts @@ -1,4 +1,5 @@ import { + type NeoMessageIconVariant, type NeoMessageVariant, NeoNotificationProgrammatic as Notif, } from '@kodadot1/brick' @@ -7,7 +8,7 @@ import { h } from 'vue' import Notification from '@/components/common/Notification.vue' import MessageNotify from '@/components/MessageNotify.vue' -type NotificationAction = { label: string, url: string } +export type NotificationAction = { label: string, url: string } type Params = { variant: NeoMessageVariant @@ -34,14 +35,20 @@ export const showNotification = ({ title, message, action, + variant, + holdTimer, + icon, params = notificationTypes.info, duration = 10000, }: { - title: string - message: string | null + title?: MaybeRef + message?: MaybeRef | null + variant?: Ref params?: Params duration?: number - action?: NotificationAction + action?: MaybeRef + holdTimer?: Ref + icon?: Ref }): void => { if (params === notificationTypes.danger) { consola.error('[Notification Error]', message) @@ -51,25 +58,19 @@ export const showNotification = ({ const componentParams = { component: h(Notification, { - title: title, - message: message!, - variant: params.variant, + title: title ? toRef(title) : title, + message: message ? toRef(message!) : message, + variant: variant ?? params.variant, duration: duration, action: action, + holdTimer: holdTimer, + icon: icon, }), variant: 'component', duration: 50000, // child component will trigger close when the real duration is ended } - Notif.open( - params.variant === 'success' - ? { - message, - duration: duration, - closable: true, - } - : componentParams, - ) + Notif.open(componentParams) } export const showLargeNotification = ({ @@ -116,8 +117,8 @@ export const infoMessage = ( export const successMessage = message => showNotification({ - title: 'Succes', - message: `[SUCCESS] ${message}`, + title: 'Success', + message: message, params: notificationTypes.success, }) @@ -149,3 +150,65 @@ export const dangerMessage = ( params: notificationTypes.danger, action: reportable ? getReportIssueAction(message) : undefined, }) + +const ifIsRef = (value: MaybeRef, otherwise: T): T => + Boolean(value) && isRef(value) && unref(value) + ? (value.value as T) + : otherwise + +const NotificationStateToVariantMap: Record< + LoadingNotificationState, + NeoMessageVariant +> = { + succeeded: 'success', + loading: 'neutral', + failed: 'danger', +} + +export type LoadingNotificationState = 'loading' | 'succeeded' | 'failed' + +export const loadingMessage = ({ + title, + message, + state, + action, +}: { + title: MaybeRef + message?: MaybeRef + state: Ref + action?: Ref +}) => { + const { $i18n } = useNuxtApp() + const stateMessage = ref(unref(message) ?? `${$i18n.t('mint.progress')}...`) + + watch( + [state], + ([state]) => { + if (state === 'succeeded') { + stateMessage.value = ifIsRef( + message, + $i18n.t('transactionLoader.completed'), + ) + } + else if (state === 'failed') { + stateMessage.value = ifIsRef(message, '') + } + }, + { + once: true, + }, + ) + + const isLoadingState = computed(() => state.value === 'loading') + + showNotification({ + title, + message: stateMessage, + variant: computed(() => NotificationStateToVariantMap[state.value]), + action: action, + holdTimer: isLoadingState, + icon: computed(() => + isLoadingState.value ? { icon: 'spinner-third', spin: true } : undefined, + ), + }) +}