feat(fe2): New user onboarding flow (#3932)
* CodeInput. verify-email page * middleware * Loading toast * Countdown only for registration * Improve middleware * Fix middleware breaking auth flow * Remove old notifications * Remove old onboarding. New segmentation * Remove skip button * Block verify email when verified * useUserEmails composable. Cancel addition * Move user emails queries * Fix fragments etc * redirect updates * HeaderWithEmptyPage * Check env before enforcing * Join workspace * Updates * Fix console warnings on login * Fix register console warnings * Working cache updates * Verify secondary email * Force onboarding off * EMAIL WIP * useIsJustRegistered state * Improve isRequired * Uneeded change * Improved slots * Updates from CR * CR comments * Only show message if forced * Update onboarding middleware * Update loading bar * ref > computed to fix onboarding * Resend tooltip. Better errors * Add other to form. * Email changes * Updates to emails * Remove force email FF * Remove FF's * Hide header on embed * Update graphql.ts * Re-add FF * Update graphql.ts * GQL Fragments * Fix build
This commit is contained in:
committed by
GitHub
parent
4f35d994b4
commit
91cb011ded
@@ -12,6 +12,7 @@
|
||||
show-label
|
||||
:disabled="!!(loading || shouldForceInviteEmail)"
|
||||
auto-focus
|
||||
autocomplete="email"
|
||||
/>
|
||||
<FormTextInput
|
||||
type="password"
|
||||
@@ -23,6 +24,7 @@
|
||||
:rules="passwordRules"
|
||||
show-label
|
||||
:disabled="loading"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<FormButton
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<FormCheckbox
|
||||
id="newsletter-consent-checkbox"
|
||||
v-model="newsletterConsent"
|
||||
name="newsletter"
|
||||
label="Opt in for exclusive Speckle news and tips"
|
||||
@@ -8,5 +9,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const newsletterConsent = ref<true | undefined>(undefined)
|
||||
const newsletterConsent = defineModel<boolean>('newsletterConsent', {
|
||||
required: true
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
:rules="emailRules"
|
||||
show-label
|
||||
:disabled="isEmailDisabled"
|
||||
autocomplete="email"
|
||||
/>
|
||||
<FormTextInput
|
||||
type="text"
|
||||
@@ -25,6 +26,7 @@
|
||||
show-label
|
||||
:disabled="loading"
|
||||
auto-focus
|
||||
autocomplete="name"
|
||||
/>
|
||||
<FormTextInput
|
||||
v-model="password"
|
||||
@@ -37,6 +39,7 @@
|
||||
:rules="passwordRules"
|
||||
show-label
|
||||
:disabled="loading"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<AuthPasswordChecks :password="password" class="mt-2 h-12 sm:h-8" />
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
<template>
|
||||
<div v-if="shouldShowBanner" class="flex flex-col px-3 pb-3">
|
||||
<div class="text-body-2xs text-foreground mb-3">{{ verifyBannerText }}</div>
|
||||
<FormButton
|
||||
size="sm"
|
||||
color="outline"
|
||||
:disabled="loading"
|
||||
@click="requestVerification"
|
||||
>
|
||||
{{ verifyBannerCtaText }}
|
||||
</FormButton>
|
||||
</div>
|
||||
<div v-else-if="noticeLoading">
|
||||
<CommonLoadingIcon size="sm" class="my-2 mx-auto" />
|
||||
</div>
|
||||
<div v-else />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
import { useApolloClient, useQuery } from '@vue/apollo-composable'
|
||||
import {
|
||||
convertThrowIntoFetchResult,
|
||||
getFirstErrorMessage
|
||||
} from '~~/lib/common/helpers/graphql'
|
||||
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
|
||||
|
||||
const reminderStateQuery = graphql(`
|
||||
query EmailVerificationBannerState {
|
||||
activeUser {
|
||||
id
|
||||
email
|
||||
verified
|
||||
hasPendingVerification
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const requestVerificationMutation = graphql(`
|
||||
mutation RequestVerification {
|
||||
requestVerification
|
||||
}
|
||||
`)
|
||||
|
||||
const apollo = useApolloClient().client
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
|
||||
const { result, loading: noticeLoading } = useQuery(reminderStateQuery)
|
||||
const user = computed(() => result.value?.activeUser || null)
|
||||
|
||||
const dismissed = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
const shouldShowBanner = computed(() => {
|
||||
if (!user.value) return false
|
||||
if (user.value.verified) return false
|
||||
if (dismissed.value) return false
|
||||
|
||||
return true
|
||||
})
|
||||
const hasPendingVerification = computed(() => !!user.value?.hasPendingVerification)
|
||||
|
||||
const verifyBannerText = computed(() => {
|
||||
if (!user.value?.email) return ''
|
||||
return hasPendingVerification.value
|
||||
? `Please check your inbox (${user.value.email}) for the verification e-mail`
|
||||
: `Please verify your e-mail address.`
|
||||
})
|
||||
|
||||
const verifyBannerCtaText = computed(() =>
|
||||
hasPendingVerification.value ? `Re-send verification` : `Send verification`
|
||||
)
|
||||
|
||||
const dismiss = () => (dismissed.value = true)
|
||||
const requestVerification = async () => {
|
||||
const userData = user.value
|
||||
if (!userData) return
|
||||
|
||||
loading.value = true
|
||||
const { data, errors } = await apollo
|
||||
.mutate({
|
||||
mutation: requestVerificationMutation,
|
||||
update: (cache, { data }) => {
|
||||
const isSuccess = !!data?.requestVerification
|
||||
if (!isSuccess) return
|
||||
|
||||
// Switch hasPendingVerification to true
|
||||
cache.modify({
|
||||
id: cache.identify(userData),
|
||||
fields: {
|
||||
hasPendingVerification: () => true
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(convertThrowIntoFetchResult)
|
||||
.finally(() => (loading.value = false))
|
||||
|
||||
if (!data?.requestVerification) {
|
||||
const errMsg = getFirstErrorMessage(errors)
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Resend failed',
|
||||
description: errMsg
|
||||
})
|
||||
} else {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Info,
|
||||
title: 'Verification e-mail sent, please check your inbox'
|
||||
})
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -39,7 +39,7 @@ import { useQuery } from '@vue/apollo-composable'
|
||||
|
||||
const route = useRoute()
|
||||
const loading = ref(false)
|
||||
const newsletterConsent = ref<true | undefined>(undefined)
|
||||
const newsletterConsent = ref<boolean>(false)
|
||||
|
||||
const { challenge } = useLoginOrRegisterUtils()
|
||||
const { signInOrSignUpWithSso } = useAuthManager()
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<nav class="fixed z-40 top-0 h-12 bg-foundation border-b border-outline-2">
|
||||
<div
|
||||
class="flex gap-8 items-center justify-between h-full w-screen py-4 px-3 sm:px-4"
|
||||
>
|
||||
<div>
|
||||
<slot name="header-left" />
|
||||
</div>
|
||||
<div>
|
||||
<slot name="header-right" />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<Component
|
||||
:is="mainComponent"
|
||||
class="flex items-center shrink-0"
|
||||
class="flex items-center shrink-0 select-none"
|
||||
:to="to"
|
||||
:target="target"
|
||||
>
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
<PortalTarget name="secondary-actions"></PortalTarget>
|
||||
<PortalTarget name="primary-actions"></PortalTarget>
|
||||
</ClientOnly>
|
||||
<!-- Notifications dropdown -->
|
||||
<HeaderNavNotifications v-if="hasNotifications" />
|
||||
<FormButton
|
||||
v-if="!activeUser"
|
||||
:to="loginUrl.fullPath"
|
||||
@@ -55,10 +53,4 @@ const loginUrl = computed(() =>
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const hasNotifications = computed(() => {
|
||||
if (!activeUser.value) return false
|
||||
if (!activeUser.value?.verified) return true
|
||||
return false
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<Menu as="div" class="flex items-center">
|
||||
<MenuButton :id="menuButtonId" v-slot="{ open: menuOpen }" as="div">
|
||||
<div
|
||||
class="relative cursor-pointer p-1 w-8 h-8 flex items-center justify-center rounded-md"
|
||||
:class="menuOpen ? 'border border-outline-2' : ''"
|
||||
>
|
||||
<span class="sr-only">Open notifications menu</span>
|
||||
<div class="relative">
|
||||
<div v-if="!menuOpen">
|
||||
<div
|
||||
class="absolute -top-1 right-0 w-1.5 h-1.5 rounded-full bg-primary animate-ping"
|
||||
></div>
|
||||
<div
|
||||
class="absolute -top-1 right-0 w-1.5 h-1.5 rounded-full bg-primary"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<BellIcon v-if="!menuOpen" class="w-5 h-5" />
|
||||
<XMarkIcon v-else class="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
</MenuButton>
|
||||
<Transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems
|
||||
class="absolute z-50 right-0 md:right-20 top-10 mt-1.5 w-full sm:w-64 origin-top-right bg-foundation-page outline outline-2 outline-primary-muted rounded-md shadow-lg overflow-hidden"
|
||||
>
|
||||
<div class="px-3 py-2 text-body-xs font-medium">Notifications</div>
|
||||
<!-- <div class="p-2 text-sm">TODO: project invites</div> -->
|
||||
<MenuItem>
|
||||
<AuthVerificationReminderMenuNotice />
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue'
|
||||
import { XMarkIcon, BellIcon } from '@heroicons/vue/24/outline'
|
||||
|
||||
const menuButtonId = useId()
|
||||
</script>
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div>
|
||||
<HeaderEmpty v-if="emptyHeader">
|
||||
<template #header-left>
|
||||
<slot name="header-left" />
|
||||
</template>
|
||||
<template #header-right>
|
||||
<slot name="header-right" />
|
||||
</template>
|
||||
</HeaderEmpty>
|
||||
<HeaderNavBar v-else />
|
||||
<div class="h-dvh w-dvh overflow-hidden flex flex-col">
|
||||
<!-- Static Spacer to allow for absolutely positioned HeaderNavBar -->
|
||||
<div class="h-12 w-full shrink-0"></div>
|
||||
|
||||
<div class="relative flex h-[calc(100dvh-3rem)]">
|
||||
<main class="w-full h-full overflow-y-auto simple-scrollbar pt-4 lg:pt-6 pb-16">
|
||||
<div class="container mx-auto px-6 md:px-8">
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
emptyHeader?: boolean
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-2 w-full">
|
||||
<CommonCard
|
||||
v-for="workspace in workspaces"
|
||||
:key="workspace.id"
|
||||
class="w-full bg-foundation"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<WorkspaceAvatar :name="workspace.name" :logo="workspace.logo" size="xl" />
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-between flex-1">
|
||||
<div class="flex flex-col flex-1">
|
||||
<h6 class="text-heading-sm">{{ workspace.name }}</h6>
|
||||
<p class="text-body-2xs text-foreground-2">{{ workspace.description }}</p>
|
||||
</div>
|
||||
<FormButton
|
||||
color="outline"
|
||||
size="sm"
|
||||
:loading="loadingStates[workspace.id]"
|
||||
:disabled="requestedWorkspaces.includes(workspace.id)"
|
||||
@click="() => processRequest(true, workspace.id)"
|
||||
>
|
||||
{{
|
||||
requestedWorkspaces.includes(workspace.id)
|
||||
? 'Requested'
|
||||
: 'Request to join'
|
||||
}}
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</CommonCard>
|
||||
<div class="mt-2 w-full">
|
||||
<FormButton size="lg" full-width @click="$emit('next')">Continue</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LimitedWorkspace } from '~/lib/common/generated/gql/graphql'
|
||||
import { dashboardRequestToJoinWorkspaceMutation } from '~~/lib/dashboard/graphql/mutations'
|
||||
import {
|
||||
convertThrowIntoFetchResult,
|
||||
getFirstErrorMessage
|
||||
} from '~~/lib/common/helpers/graphql'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
import { useMutation } from '@vue/apollo-composable'
|
||||
|
||||
defineProps<{
|
||||
workspaces: LimitedWorkspace[]
|
||||
}>()
|
||||
|
||||
defineEmits(['next'])
|
||||
|
||||
const mixpanel = useMixpanel()
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
|
||||
const loadingStates = ref<Record<string, boolean>>({})
|
||||
|
||||
const { mutate: requestToJoin } = useMutation(dashboardRequestToJoinWorkspaceMutation)
|
||||
|
||||
const requestedWorkspaces = ref<string[]>([])
|
||||
|
||||
const processRequest = async (accept: boolean, workspaceId: string) => {
|
||||
if (accept) {
|
||||
loadingStates.value[workspaceId] = true
|
||||
|
||||
try {
|
||||
const result = await requestToJoin({
|
||||
input: { workspaceId }
|
||||
}).catch(convertThrowIntoFetchResult)
|
||||
|
||||
if (result?.data) {
|
||||
requestedWorkspaces.value.push(workspaceId)
|
||||
mixpanel.track('Workspace Join Request Sent', {
|
||||
workspaceId,
|
||||
location: 'onboarding',
|
||||
// eslint-disable-next-line camelcase
|
||||
workspace_id: workspaceId
|
||||
})
|
||||
|
||||
triggerNotification({
|
||||
title: 'Request sent',
|
||||
description: 'Your request to join the workspace has been sent.',
|
||||
type: ToastNotificationType.Success
|
||||
})
|
||||
} else {
|
||||
const errorMessage = getFirstErrorMessage(result?.errors)
|
||||
triggerNotification({
|
||||
title: 'Failed to send request',
|
||||
description: errorMessage,
|
||||
type: ToastNotificationType.Danger
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
loadingStates.value[workspaceId] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,445 +0,0 @@
|
||||
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
|
||||
<!-- eslint-disable vuejs-accessibility/click-events-have-key-events -->
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div
|
||||
:class="`${
|
||||
background ? 'mx-2 sm:mx-auto px-2 bg-foundation rounded-md shadow-xl' : ''
|
||||
} ${allCompleted ? 'max-w-lg mx-auto' : ''}`"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
v-if="!allCompleted"
|
||||
:class="`hidden sm:grid gap-2 ${
|
||||
showIntro ? 'px-4 grid-cols-5' : 'grid-cols-4'
|
||||
}`"
|
||||
>
|
||||
<div
|
||||
v-if="showIntro"
|
||||
class="flex-col justify-around px-2 h-full py-2 md:col-span-1 hidden lg:flex"
|
||||
>
|
||||
<div class="text-heading-sm">Quickstart checklist</div>
|
||||
<div class="text-body-sm text-foreground-2">
|
||||
Become a Speckle pro in four steps!
|
||||
</div>
|
||||
<div class="space-x-1">
|
||||
<FormButton v-if="!allCompleted" size="sm" @click="dismissChecklist()">
|
||||
I'll do it later
|
||||
</FormButton>
|
||||
<FormButton
|
||||
v-if="!allCompleted"
|
||||
color="subtle"
|
||||
size="sm"
|
||||
@click="dismissChecklistForever()"
|
||||
>
|
||||
Don't show again
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 grow col-span-5 lg:col-span-4">
|
||||
<div
|
||||
v-for="(step, idx) in steps"
|
||||
:key="idx"
|
||||
class="py-2 col-span-4 sm:col-span-2 lg:col-span-1"
|
||||
>
|
||||
<div
|
||||
:class="`
|
||||
${
|
||||
step.active
|
||||
? 'bg-primary text-foreground-on-primary shadow hover:shadow-md scale-100'
|
||||
: 'text-foreground-2 hover:bg-primary-muted scale-95'
|
||||
}
|
||||
transition rounded-md flex flex-col justify-between px-2 cursor-pointer h-full`"
|
||||
@click.stop="
|
||||
!step.active
|
||||
? activateStep(idx)
|
||||
: idx === 0 || steps[idx - 1].completed
|
||||
? step.action()
|
||||
: goToFirstUncompletedStep()
|
||||
"
|
||||
>
|
||||
<div
|
||||
:class="`text-lg sm:text-xl font-medium flex items-center justify-between ${
|
||||
step.active ? 'text-foreground-on-primary' : 'text-foreground-2'
|
||||
}`"
|
||||
>
|
||||
<span>{{ idx + 1 }}</span>
|
||||
<Component
|
||||
:is="step.icon"
|
||||
v-if="!step.completed"
|
||||
:class="`w-4 h-4 mt-1`"
|
||||
/>
|
||||
<CheckCircleIcon v-else class="w-4 h-4 mt-1 text-primary" />
|
||||
</div>
|
||||
<div
|
||||
:class="`${
|
||||
step.active
|
||||
? 'font-medium text-sm sm:text-base text-foreground-on-primary'
|
||||
: ''
|
||||
}`"
|
||||
>
|
||||
{{ step.title }}
|
||||
</div>
|
||||
<div class="text-xs mt-[2px]">{{ step.blurb }}</div>
|
||||
<div
|
||||
class="flex items-center justify-between"
|
||||
:class="step.active ? 'h-10' : 'h-4'"
|
||||
>
|
||||
<div
|
||||
v-if="idx === 0 || steps[idx - 1].completed"
|
||||
class="flex justify-between items-center py-2 w-full"
|
||||
>
|
||||
<FormButton
|
||||
v-if="!step.completed && step.active"
|
||||
:disabled="!step.active"
|
||||
color="outline"
|
||||
size="sm"
|
||||
@click.stop="step.action"
|
||||
>
|
||||
{{ step.cta }}
|
||||
</FormButton>
|
||||
|
||||
<FormButton
|
||||
v-if="step.active && !step.completed"
|
||||
v-tippy="'Mark completed'"
|
||||
text
|
||||
link
|
||||
size="sm"
|
||||
color="outline"
|
||||
@click.stop="markComplete(idx)"
|
||||
>
|
||||
<!-- Mark as complete -->
|
||||
<OutlineCheckCircleIcon class="w-4 h-4 text-foundation" />
|
||||
</FormButton>
|
||||
<span v-if="step.completed" class="text-xs font-medium">
|
||||
Completed!
|
||||
</span>
|
||||
<FormButton
|
||||
v-if="step.completed && step.active"
|
||||
size="sm"
|
||||
color="outline"
|
||||
@click.stop="step.action"
|
||||
>
|
||||
{{ step.postCompletionCta }}
|
||||
</FormButton>
|
||||
</div>
|
||||
<div v-else-if="step.active" class="text-sm">
|
||||
<FormButton
|
||||
size="sm"
|
||||
color="outline"
|
||||
@click.stop="goToFirstUncompletedStep()"
|
||||
>
|
||||
Complete the previous step!
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="showIntro"
|
||||
class="lg:hidden col-span-5 pb-3 pt-2 text-center space-x-2"
|
||||
>
|
||||
<FormButton v-if="!allCompleted" size="sm" @click="dismissChecklist()">
|
||||
I'll do it later
|
||||
</FormButton>
|
||||
<FormButton
|
||||
v-if="!allCompleted"
|
||||
color="subtle"
|
||||
size="sm"
|
||||
@click="dismissChecklistForever()"
|
||||
>
|
||||
Don't show again
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="relative hidden sm:flex flex-col sm:flex-row items-center justify-center flex-1 gap-x-2 py-4"
|
||||
>
|
||||
<div class="w-6 h-6">
|
||||
<!-- <CheckCircleIcon class="absolute w-6 h-6 text-primary" /> -->
|
||||
<CheckCircleIcon class="w-6 h-6 text-primary animate-ping animate-pulse" />
|
||||
</div>
|
||||
<div class="text-sm max-w-lg text-center sm:text-left">
|
||||
<b>All done!</b>
|
||||
PS: the
|
||||
<FormButton to="https://speckle.community" target="_blank" link>
|
||||
Community Forum
|
||||
</FormButton>
|
||||
is there to help!
|
||||
</div>
|
||||
<div class="absolute right-2 top-3">
|
||||
<FormButton
|
||||
color="outline"
|
||||
:icon-left="XMarkIcon"
|
||||
hide-text
|
||||
@click="closeChecklist()"
|
||||
>
|
||||
Close
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
This is used as a dismissal prompt from when showing the checklist on top of the
|
||||
viewer. It does not directly dismiss the checklist as we still want to show it
|
||||
on the main dasboard page.
|
||||
-->
|
||||
<div v-if="showBottomEscape && !allCompleted" class="text-center mt-2">
|
||||
<FormButton @click="$emit('dismiss')">
|
||||
I'll do it later - let me explore first!
|
||||
</FormButton>
|
||||
</div>
|
||||
|
||||
<OnboardingDialogManager
|
||||
v-model:open="showManagerDownloadDialog"
|
||||
@done="markComplete(0)"
|
||||
@cancel="showManagerDownloadDialog = false"
|
||||
></OnboardingDialogManager>
|
||||
<OnboardingDialogAccountLink
|
||||
v-model:open="showAccountLinkDialog"
|
||||
@done="markComplete(1)"
|
||||
@cancel="showAccountLinkDialog = false"
|
||||
>
|
||||
<template #header>Desktop login</template>
|
||||
</OnboardingDialogAccountLink>
|
||||
<OnboardingDialogFirstSend
|
||||
v-model:open="showFirstSendDialog"
|
||||
@done="markComplete(2)"
|
||||
@cancel="showFirstSendDialog = false"
|
||||
>
|
||||
<template #header>Your first upload</template>
|
||||
</OnboardingDialogFirstSend>
|
||||
<InviteDialogServer
|
||||
v-model:open="showServerInviteDialog"
|
||||
@update:open="(v) => (!v ? markComplete(3) : '')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ShareIcon,
|
||||
ComputerDesktopIcon,
|
||||
UserPlusIcon,
|
||||
CloudArrowUpIcon,
|
||||
XMarkIcon
|
||||
} from '@heroicons/vue/24/solid'
|
||||
import { CheckCircleIcon as OutlineCheckCircleIcon } from '@heroicons/vue/24/outline'
|
||||
import { useSynchronizedCookie } from '~~/lib/common/composables/reactiveCookie'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
showIntro?: boolean
|
||||
showBottomEscape?: boolean
|
||||
background?: boolean
|
||||
}>(),
|
||||
{
|
||||
showIntro: false,
|
||||
showBottomEscape: false,
|
||||
background: false
|
||||
}
|
||||
)
|
||||
|
||||
const mp = useMixpanel()
|
||||
|
||||
const emit = defineEmits(['dismiss'])
|
||||
|
||||
const showManagerDownloadDialog = ref(false)
|
||||
const showAccountLinkDialog = ref(false)
|
||||
const showFirstSendDialog = ref(false)
|
||||
const showServerInviteDialog = ref(false)
|
||||
|
||||
const hasDownloadedManager = useSynchronizedCookie<boolean>(`hasDownloadedManager`, {
|
||||
default: () => false
|
||||
})
|
||||
const hasLinkedAccount = useSynchronizedCookie<boolean>(`hasLinkedAccount`, {
|
||||
default: () => false
|
||||
})
|
||||
const hasViewedFirstSend = useSynchronizedCookie<boolean>(`hasViewedFirstSend`, {
|
||||
default: () => false
|
||||
})
|
||||
const hasSharedProject = useSynchronizedCookie<boolean>(`hasSharedProject`, {
|
||||
default: () => false
|
||||
})
|
||||
const hasCompletedChecklistV1 = useSynchronizedCookie<boolean>(
|
||||
`hasCompletedChecklistV1`,
|
||||
{ default: () => false }
|
||||
)
|
||||
const hasDismissedChecklistTime = useSynchronizedCookie<string | undefined>(
|
||||
`hasDismissedChecklistTime`,
|
||||
{ default: () => undefined }
|
||||
)
|
||||
|
||||
const hasDismissedChecklistForever = useSynchronizedCookie<boolean | undefined>(
|
||||
`hasDismissedChecklistForever`,
|
||||
{ default: () => false }
|
||||
)
|
||||
|
||||
const getStatus = () => {
|
||||
return {
|
||||
hasDownloadedManager: hasDownloadedManager.value,
|
||||
hasLinkedAccount: hasLinkedAccount.value,
|
||||
hasViewedFirstSend: hasViewedFirstSend.value,
|
||||
hasSharedProject: hasSharedProject.value
|
||||
}
|
||||
}
|
||||
|
||||
const steps = ref([
|
||||
{
|
||||
title: 'Install Manager ⚙️',
|
||||
blurb: 'Use Manager to install the Speckle Connectors for your apps!',
|
||||
active: false,
|
||||
cta: "Let's go!",
|
||||
postCompletionCta: 'Download again',
|
||||
action: () => {
|
||||
showManagerDownloadDialog.value = true
|
||||
},
|
||||
completionAction: () => {
|
||||
showManagerDownloadDialog.value = false
|
||||
hasDownloadedManager.value = true
|
||||
mp.track('Onboarding Action', {
|
||||
type: 'action',
|
||||
name: 'checklist',
|
||||
action: 'step-completed',
|
||||
stepName: 'download manager'
|
||||
})
|
||||
},
|
||||
completed: hasDownloadedManager.value,
|
||||
icon: ComputerDesktopIcon
|
||||
},
|
||||
{
|
||||
title: 'Log in 🔑',
|
||||
blurb: 'Authorise our application connectors to send data to Speckle.',
|
||||
active: false,
|
||||
cta: "Let's go!",
|
||||
postCompletionCta: 'Login again',
|
||||
action: () => {
|
||||
showAccountLinkDialog.value = true
|
||||
},
|
||||
completionAction: () => {
|
||||
showAccountLinkDialog.value = false
|
||||
hasLinkedAccount.value = true
|
||||
mp.track('Onboarding Action', {
|
||||
type: 'action',
|
||||
name: 'checklist',
|
||||
action: 'step-completed',
|
||||
stepName: 'manager login'
|
||||
})
|
||||
},
|
||||
completed: hasLinkedAccount.value,
|
||||
icon: UserPlusIcon
|
||||
},
|
||||
{
|
||||
title: 'Your first model upload ⬆️',
|
||||
blurb: 'Use your favourite design app to send your first model to Speckle.',
|
||||
active: false,
|
||||
cta: "Let's go!",
|
||||
postCompletionCta: 'Show again',
|
||||
action: () => {
|
||||
showFirstSendDialog.value = true
|
||||
},
|
||||
completionAction: () => {
|
||||
showFirstSendDialog.value = false
|
||||
hasViewedFirstSend.value = true
|
||||
mp.track('Onboarding Action', {
|
||||
type: 'action',
|
||||
name: 'checklist',
|
||||
action: 'step-completed',
|
||||
stepName: 'first send'
|
||||
})
|
||||
},
|
||||
completed: hasViewedFirstSend.value,
|
||||
icon: CloudArrowUpIcon
|
||||
},
|
||||
{
|
||||
title: 'Enable multiplayer 📢',
|
||||
blurb: 'Share your project with your colleagues!',
|
||||
active: false,
|
||||
cta: "Let's go!",
|
||||
postCompletionCta: 'Invite again',
|
||||
action: () => {
|
||||
showServerInviteDialog.value = true
|
||||
//TODO: modify server invite dialog to include searchable project dropdown
|
||||
},
|
||||
completionAction: () => {
|
||||
showServerInviteDialog.value = false
|
||||
hasSharedProject.value = true
|
||||
mp.track('Onboarding Action', {
|
||||
type: 'action',
|
||||
name: 'checklist',
|
||||
action: 'step-completed',
|
||||
stepName: 'first share'
|
||||
})
|
||||
},
|
||||
completed: hasSharedProject.value,
|
||||
icon: ShareIcon
|
||||
}
|
||||
])
|
||||
|
||||
const activateStep = (idx: number) => {
|
||||
steps.value.forEach((s, index) => (s.active = idx === index))
|
||||
}
|
||||
|
||||
const markComplete = (idx: number) => {
|
||||
steps.value[idx].completed = true
|
||||
steps.value[idx].active = false
|
||||
steps.value[idx].completionAction()
|
||||
mp.track('Onboarding Action', {
|
||||
type: 'action',
|
||||
name: 'checklist',
|
||||
action: 'mark-complete',
|
||||
step: idx,
|
||||
status: getStatus()
|
||||
})
|
||||
activateStep(idx + 1)
|
||||
}
|
||||
|
||||
const goToFirstUncompletedStep = () => {
|
||||
const firstNonCompleteStepIndex = steps.value.findIndex((s) => s.completed === false)
|
||||
activateStep(firstNonCompleteStepIndex)
|
||||
|
||||
if (import.meta.client) {
|
||||
mp.track('Onboarding Action', {
|
||||
type: 'action',
|
||||
name: 'checklist',
|
||||
action: 'goto-uncompleted-step',
|
||||
status: getStatus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const allCompleted = computed(() => steps.value.every((step) => step.completed))
|
||||
|
||||
const closeChecklist = () => {
|
||||
hasCompletedChecklistV1.value = true
|
||||
}
|
||||
|
||||
const dismissChecklist = () => {
|
||||
hasDismissedChecklistTime.value = Date.now().toString()
|
||||
emit('dismiss')
|
||||
mp.track('Onboarding Action', {
|
||||
type: 'action',
|
||||
name: 'checklist',
|
||||
action: 'dismiss',
|
||||
status: getStatus()
|
||||
})
|
||||
}
|
||||
|
||||
const dismissChecklistForever = () => {
|
||||
hasDismissedChecklistForever.value = true
|
||||
emit('dismiss')
|
||||
mp.track('Onboarding Action', {
|
||||
type: 'action',
|
||||
name: 'checklist',
|
||||
action: 'dismiss-forever',
|
||||
status: getStatus()
|
||||
})
|
||||
}
|
||||
|
||||
goToFirstUncompletedStep()
|
||||
</script>
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<form class="w-full flex flex-col gap-4" @submit="onSubmit">
|
||||
<OnboardingQuestionsRoleSelect v-model="values.role" name="role" required />
|
||||
<OnboardingQuestionsPlanSelect v-model="values.plan" name="plan" required />
|
||||
<OnboardingQuestionsSourceSelect v-model="values.source" name="source" required />
|
||||
<div class="mt-2 flex flex-col gap-4">
|
||||
<FormButton size="lg" :disabled="!meta.valid || isSubmitting" submit full-width>
|
||||
Continue
|
||||
</FormButton>
|
||||
<div class="opacity-70 hover:opacity-100 max-w-max mx-auto px-1">
|
||||
<FormButton
|
||||
v-if="!isOnboardingForced"
|
||||
size="sm"
|
||||
text
|
||||
link
|
||||
color="subtle"
|
||||
full-width
|
||||
@click="setUserOnboardingComplete"
|
||||
>
|
||||
Skip
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useForm } from 'vee-validate'
|
||||
import type {
|
||||
OnboardingRole,
|
||||
OnboardingPlan,
|
||||
OnboardingSource
|
||||
} from '~/lib/auth/helpers/onboarding'
|
||||
import { useProcessOnboarding } from '~~/lib/auth/composables/onboarding'
|
||||
import { homeRoute } from '~/lib/common/helpers/route'
|
||||
|
||||
const isOnboardingForced = useIsOnboardingForced()
|
||||
|
||||
const { setUserOnboardingComplete, setMixpanelSegments } = useProcessOnboarding()
|
||||
|
||||
const { handleSubmit, meta, isSubmitting, values } = useForm({
|
||||
initialValues: {
|
||||
role: undefined as OnboardingRole | undefined,
|
||||
plan: [] as OnboardingPlan[],
|
||||
source: undefined as OnboardingSource | undefined
|
||||
}
|
||||
})
|
||||
|
||||
const onSubmit = handleSubmit(async () => {
|
||||
if (values.role) {
|
||||
setMixpanelSegments({ role: values.role })
|
||||
}
|
||||
await setUserOnboardingComplete()
|
||||
navigateTo(homeRoute)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<FormSelectMulti
|
||||
v-bind="props"
|
||||
id="plan-select"
|
||||
v-model="selectedValue"
|
||||
label="What are you planning to do with Speckle?"
|
||||
placeholder="Select all that apply"
|
||||
required
|
||||
:rules="isRequired"
|
||||
name="plan"
|
||||
show-label
|
||||
allow-unset
|
||||
clearable
|
||||
:items="plans"
|
||||
>
|
||||
<template #option="{ item }">
|
||||
<div class="label label--light">
|
||||
{{ PlanTitleMap[item] }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #something-selected="{ value }">
|
||||
<template v-if="value.length === 1">
|
||||
{{ PlanTitleMap[isArrayValue(value) ? value[0] : value] }}
|
||||
</template>
|
||||
<template v-else>{{ value.length }} items selected</template>
|
||||
</template>
|
||||
</FormSelectMulti>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useFormSelectChildInternals } from '@speckle/ui-components'
|
||||
import { OnboardingPlan, PlanTitleMap } from '~/lib/auth/helpers/onboarding'
|
||||
import { isRequired } from '~~/lib/common/helpers/validation'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: OnboardingPlan[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: OnboardingPlan | OnboardingPlan[] | undefined): void
|
||||
}>()
|
||||
|
||||
const plans = Object.values(OnboardingPlan)
|
||||
|
||||
const { selectedValue, isArrayValue } = useFormSelectChildInternals<OnboardingPlan>({
|
||||
props: toRefs(props),
|
||||
emit
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<FormSelectBase
|
||||
v-bind="props"
|
||||
id="role-select"
|
||||
v-model="selectedValue"
|
||||
label="What's your role?"
|
||||
placeholder="Select one"
|
||||
required
|
||||
:rules="isRequired"
|
||||
name="role"
|
||||
show-label
|
||||
allow-unset
|
||||
clearable
|
||||
:items="roles"
|
||||
>
|
||||
<template #option="{ item }">
|
||||
<div class="label label--light">
|
||||
{{ RoleTitleMap[item] }}
|
||||
</div>
|
||||
</template>
|
||||
<template #something-selected="{ value }">
|
||||
<span>{{ RoleTitleMap[isArrayValue(value) ? value[0] : value] }}</span>
|
||||
</template>
|
||||
</FormSelectBase>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useFormSelectChildInternals } from '@speckle/ui-components'
|
||||
import { OnboardingRole, RoleTitleMap } from '~/lib/auth/helpers/onboarding'
|
||||
import { isRequired } from '~~/lib/common/helpers/validation'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: OnboardingRole
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: OnboardingRole | OnboardingRole[] | undefined): void
|
||||
}>()
|
||||
|
||||
const roles = Object.values(OnboardingRole)
|
||||
|
||||
const { selectedValue, isArrayValue } = useFormSelectChildInternals<OnboardingRole>({
|
||||
props: toRefs(props),
|
||||
emit
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<FormSelectBase
|
||||
v-bind="props"
|
||||
id="source-select"
|
||||
v-model="selectedValue"
|
||||
label="How did you hear about Speckle?"
|
||||
placeholder="Select one"
|
||||
required
|
||||
:rules="isRequired"
|
||||
name="source"
|
||||
show-label
|
||||
allow-unset
|
||||
clearable
|
||||
:items="sources"
|
||||
>
|
||||
<template #option="{ item }">
|
||||
<div class="label label--light">
|
||||
{{ SourceTitleMap[item] }}
|
||||
</div>
|
||||
</template>
|
||||
<template #something-selected="{ value }">
|
||||
<span>{{ SourceTitleMap[isArrayValue(value) ? value[0] : value] }}</span>
|
||||
</template>
|
||||
</FormSelectBase>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useFormSelectChildInternals } from '@speckle/ui-components'
|
||||
import { OnboardingSource, SourceTitleMap } from '~/lib/auth/helpers/onboarding'
|
||||
import { isRequired } from '~~/lib/common/helpers/validation'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: OnboardingSource
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: 'update:modelValue',
|
||||
value: OnboardingSource | OnboardingSource[] | undefined
|
||||
): void
|
||||
}>()
|
||||
|
||||
const sources = Object.values(OnboardingSource)
|
||||
|
||||
const { selectedValue, isArrayValue } = useFormSelectChildInternals<OnboardingSource>({
|
||||
props: toRefs(props),
|
||||
emit
|
||||
})
|
||||
</script>
|
||||
@@ -1,38 +1,33 @@
|
||||
<template>
|
||||
<LayoutDialog
|
||||
v-model:open="isOpen"
|
||||
title="Delete email address"
|
||||
:title="cancel ? 'Cancel adding email' : 'Delete email address'"
|
||||
max-width="xs"
|
||||
:buttons="dialogButtons"
|
||||
>
|
||||
<p class="text-body-xs text-foreground mb-2">
|
||||
Are you sure you want to delete
|
||||
<span class="font-medium">{{ email }}</span>
|
||||
from your account?
|
||||
{{
|
||||
cancel
|
||||
? `Are you sure you want to cancel adding ${email?.email} to your account?`
|
||||
: `Are you sure you want to delete ${email?.email} from your account?`
|
||||
}}
|
||||
</p>
|
||||
</LayoutDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LayoutDialogButton } from '@speckle/ui-components'
|
||||
import { settingsDeleteUserEmailMutation } from '~/lib/settings/graphql/mutations'
|
||||
import { useMutation } from '@vue/apollo-composable'
|
||||
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
|
||||
import {
|
||||
getFirstErrorMessage,
|
||||
convertThrowIntoFetchResult
|
||||
} from '~~/lib/common/helpers/graphql'
|
||||
import { useMixpanel } from '~/lib/core/composables/mp'
|
||||
import type { UserEmail } from '~/lib/common/generated/gql/graphql'
|
||||
import { useUserEmails } from '~/lib/user/composables/emails'
|
||||
|
||||
const props = defineProps<{
|
||||
emailId: string
|
||||
email: string
|
||||
email?: UserEmail
|
||||
cancel?: boolean
|
||||
}>()
|
||||
|
||||
const isOpen = defineModel<boolean>('open', { required: true })
|
||||
|
||||
const { mutate: deleteMutation } = useMutation(settingsDeleteUserEmailMutation)
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
const mixpanel = useMixpanel()
|
||||
const { deleteUserEmail } = useUserEmails()
|
||||
|
||||
const dialogButtons = computed((): LayoutDialogButton[] => [
|
||||
{
|
||||
@@ -43,7 +38,7 @@ const dialogButtons = computed((): LayoutDialogButton[] => [
|
||||
}
|
||||
},
|
||||
{
|
||||
text: 'Delete',
|
||||
text: props.cancel ? 'Confirm' : 'Delete',
|
||||
props: { color: 'primary' },
|
||||
onClick: () => {
|
||||
onDeleteEmail()
|
||||
@@ -52,24 +47,10 @@ const dialogButtons = computed((): LayoutDialogButton[] => [
|
||||
])
|
||||
|
||||
const onDeleteEmail = async () => {
|
||||
const result = await deleteMutation({ input: { id: props.emailId } }).catch(
|
||||
convertThrowIntoFetchResult
|
||||
)
|
||||
if (result?.data) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: `${props.email} deleted`
|
||||
})
|
||||
|
||||
mixpanel.track('Email Deleted')
|
||||
} else {
|
||||
const errorMessage = getFirstErrorMessage(result?.errors)
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: errorMessage
|
||||
})
|
||||
if (!props.email) return
|
||||
const success = await deleteUserEmail(props.email)
|
||||
if (success) {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
isOpen.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
<template>
|
||||
<ul class="flex flex-col">
|
||||
<SettingsUserEmailListItem
|
||||
v-for="email in emailData"
|
||||
:key="email.id"
|
||||
:email-data="email"
|
||||
/>
|
||||
<ul
|
||||
class="flex flex-col border border-outline-2 rounded-lg divide-y divide-outline-2 mt-4"
|
||||
>
|
||||
<li v-for="email in sortedEmails" :key="email.id">
|
||||
<SettingsUserEmailListItem :email-data="email" />
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SettingsUserEmailCards_UserEmailFragment } from '~~/lib/common/generated/gql/graphql'
|
||||
import { useUserEmails } from '~/lib/user/composables/emails'
|
||||
import { sortBy } from 'lodash-es'
|
||||
|
||||
defineProps<{
|
||||
emailData: SettingsUserEmailCards_UserEmailFragment[]
|
||||
}>()
|
||||
const { emails } = useUserEmails()
|
||||
|
||||
const sortedEmails = computed(() =>
|
||||
sortBy(emails.value, [(email) => !email.primary, 'email'])
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<template>
|
||||
<li
|
||||
class="border-outline-2 border-x border-b first:border-t first:rounded-t-lg last:rounded-b-lg p-6 border-b-outline-3 last:border-b-outline-2"
|
||||
>
|
||||
<div class="p-6">
|
||||
<div
|
||||
v-if="emailData.primary || !emailData.verified"
|
||||
class="flex w-full gap-x-2 pb-4 md:pb-3"
|
||||
@@ -18,9 +16,9 @@
|
||||
v-if="!emailData.verified"
|
||||
color="outline"
|
||||
size="sm"
|
||||
@click="resendVerificationEmail"
|
||||
@click="handleVerifyEmail"
|
||||
>
|
||||
Resend verification email
|
||||
Verify email
|
||||
</FormButton>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row">
|
||||
@@ -66,38 +64,21 @@
|
||||
|
||||
<SettingsUserEmailDeleteDialog
|
||||
v-model:open="showDeleteDialog"
|
||||
:email-id="emailData.id"
|
||||
:email="emailData.email"
|
||||
:email="emailData"
|
||||
:is-verifying="!emailData.verified"
|
||||
/>
|
||||
</li>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SettingsUserEmailCards_UserEmailFragment } from '~~/lib/common/generated/gql/graphql'
|
||||
import { useGlobalToast, ToastNotificationType } from '~~/lib/common/composables/toast'
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
import { useMutation } from '@vue/apollo-composable'
|
||||
import { settingsNewEmailVerificationMutation } from '~~/lib/settings/graphql/mutations'
|
||||
import {
|
||||
getFirstErrorMessage,
|
||||
convertThrowIntoFetchResult
|
||||
} from '~~/lib/common/helpers/graphql'
|
||||
|
||||
graphql(`
|
||||
fragment SettingsUserEmailCards_UserEmail on UserEmail {
|
||||
email
|
||||
id
|
||||
primary
|
||||
verified
|
||||
}
|
||||
`)
|
||||
import type { UserEmail } from '~~/lib/common/generated/gql/graphql'
|
||||
import { useUserEmails } from '~/lib/user/composables/emails'
|
||||
|
||||
const props = defineProps<{
|
||||
emailData: SettingsUserEmailCards_UserEmailFragment
|
||||
emailData: UserEmail
|
||||
}>()
|
||||
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
const { mutate: resendMutation } = useMutation(settingsNewEmailVerificationMutation)
|
||||
const { resendVerificationEmail } = useUserEmails()
|
||||
|
||||
const showDeleteDialog = ref(false)
|
||||
const showSetPrimaryDialog = ref(false)
|
||||
@@ -108,7 +89,6 @@ const primaryTooltip = computed(() => {
|
||||
} else if (!props.emailData.verified) {
|
||||
return 'Unverified emails cannot be set as primary'
|
||||
}
|
||||
|
||||
return undefined
|
||||
})
|
||||
|
||||
@@ -118,10 +98,14 @@ const description = computed(() => {
|
||||
} else if (!props.emailData.verified) {
|
||||
return 'Unverified emails cannot be set as primary'
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const handleVerifyEmail = async () => {
|
||||
await resendVerificationEmail(props.emailData)
|
||||
navigateTo(`/verify-email?emailId=${props.emailData.id}`)
|
||||
}
|
||||
|
||||
const toggleSetPrimaryDialog = () => {
|
||||
showSetPrimaryDialog.value = true
|
||||
}
|
||||
@@ -129,22 +113,4 @@ const toggleSetPrimaryDialog = () => {
|
||||
const toggleDeleteDialog = () => {
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
const resendVerificationEmail = async () => {
|
||||
const result = await resendMutation({ input: { id: props.emailData.id } }).catch(
|
||||
convertThrowIntoFetchResult
|
||||
)
|
||||
if (result?.data) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: `Verification mail sent to ${props.emailData.email}`
|
||||
})
|
||||
} else {
|
||||
const errorMessage = getFirstErrorMessage(result?.errors)
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: errorMessage
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="relative">
|
||||
<button class="hidden sm:block pointer-events-auto group" @click="toggle(index)">
|
||||
<div
|
||||
v-show="!item.viewed"
|
||||
class="animate-ping absolute bg-primary rounded-full h-8 w-8"
|
||||
></div>
|
||||
<div
|
||||
class="sm:absolute bg-foundation group-hover:scale-125 scale transition rounded-full h-8 w-8 flex items-center justify-center text-primary cursor-pointer select-none text-sm font-medium"
|
||||
>
|
||||
<span>{{ index + 1 }}</span>
|
||||
<!-- <span v-if="!expanded">{{ index + 1 }}</span>
|
||||
<span v-else><XMarkIcon class="h-6 w-6" /></span> -->
|
||||
</div>
|
||||
</button>
|
||||
<Transition
|
||||
enter-from-class="opacity-0"
|
||||
leave-to-class="opacity-0"
|
||||
enter-active-class="transition duration-300"
|
||||
leave-active-class="transition duration-300"
|
||||
>
|
||||
<div
|
||||
v-show="item.expanded"
|
||||
class="transition bg-foundation-page border border-outline-3 rounded-lg shadow-md mb-8 mx-2 gap-2 sm:gap-4 sm:ml-12 sm:max-w-xs pointer-events-auto"
|
||||
>
|
||||
<div
|
||||
class="sm:hidden flex items-center justify-center w-full gap-3 mt-1 mb-3"
|
||||
>
|
||||
<div
|
||||
class="bg-primary rounded-full"
|
||||
:class="index === 0 ? 'h-3 w-3' : 'h-2 w-2 opacity-50'"
|
||||
></div>
|
||||
<div
|
||||
class="bg-primary rounded-full"
|
||||
:class="index === 1 ? 'h-3 w-3' : 'h-2 w-2 opacity-50'"
|
||||
></div>
|
||||
<div
|
||||
class="bg-primary rounded-full"
|
||||
:class="index === 2 ? 'h-3 w-3' : 'h-2 w-2 opacity-50'"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between pointer-events-auto px-6 py-2 border-t border-outline-3"
|
||||
>
|
||||
<slot name="actions">
|
||||
<FormButton text size="sm" color="outline" @click="$emit('skip')">
|
||||
Skip
|
||||
</FormButton>
|
||||
<div class="flex justify-center items-center space-x-2">
|
||||
<FormButton
|
||||
v-show="index !== 0"
|
||||
size="sm"
|
||||
color="outline"
|
||||
text
|
||||
@click="prev(index)"
|
||||
>
|
||||
<ArrowLeftIcon class="h-3 w-3 mr-1" />
|
||||
Previous
|
||||
</FormButton>
|
||||
<div v-if="index === 2">
|
||||
<div v-if="!disableNext" v-tippy="'First add another model'">
|
||||
<FormButton disabled>Finish</FormButton>
|
||||
</div>
|
||||
<FormButton v-else @click="$emit('skip')">Finish</FormButton>
|
||||
</div>
|
||||
<FormButton v-else :icon-right="ArrowRightIcon" @click="next(index)">
|
||||
Next
|
||||
</FormButton>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Vector3 } from 'three'
|
||||
import { ArrowRightIcon, ArrowLeftIcon } from '@heroicons/vue/24/solid'
|
||||
import { useInjectedViewerState } from '~~/lib/viewer/composables/setup'
|
||||
import type { SlideshowItem } from '~~/lib/tour/slideshowItems'
|
||||
|
||||
const { next, prev, toggle } = inject('slideshowActions') as {
|
||||
next: (currentIndex: number) => void
|
||||
prev: (currentIndex: number) => void
|
||||
toggle: (i: number) => void
|
||||
}
|
||||
|
||||
defineEmits(['skip', 'previous', 'next'])
|
||||
|
||||
const props = defineProps<{
|
||||
index: number
|
||||
item: SlideshowItem
|
||||
disableNext: boolean
|
||||
}>()
|
||||
|
||||
const {
|
||||
ui: {
|
||||
camera: { position, target }
|
||||
}
|
||||
} = useInjectedViewerState()
|
||||
|
||||
watchEffect(() => {
|
||||
if (props.item.expanded) setView()
|
||||
})
|
||||
|
||||
function setView() {
|
||||
const camPos = props.item.camPos
|
||||
position.value = new Vector3(camPos[0], camPos[1], camPos[2])
|
||||
target.value = new Vector3(camPos[3], camPos[4], camPos[5])
|
||||
}
|
||||
</script>
|
||||
@@ -1,25 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative max-w-4xl w-screen h-[100dvh] flex items-center justify-center z-50"
|
||||
>
|
||||
<TourSegmentation v-if="showSegmentation" />
|
||||
<TourSlideshow v-else @next="$emit('complete')" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useViewerTour } from '~/lib/viewer/composables/tour'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
|
||||
defineEmits(['complete'])
|
||||
|
||||
const { showSegmentation } = useViewerTour()
|
||||
|
||||
const mp = useMixpanel()
|
||||
watch(showSegmentation, (val) => {
|
||||
mp.track('Onboarding Action', {
|
||||
type: 'action',
|
||||
name: 'step-activation',
|
||||
stepName: val ? 'slideshow' : 'segmentation'
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -1,144 +0,0 @@
|
||||
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
|
||||
<template>
|
||||
<div class="max-w-xl w-screen h-[100dvh] flex items-center justify-center">
|
||||
<Transition
|
||||
enter-from-class="opacity-0"
|
||||
enter-active-class="transition duration-300"
|
||||
>
|
||||
<!-- eslint-disable-next-line vuejs-accessibility/mouse-events-have-key-events -->
|
||||
<div
|
||||
v-show="step === 0"
|
||||
class="border border-outline bg-foundation text-foreground backdrop-blur shadow-lg rounded-xl p-4 space-y-2 absolute pointer-events-auto mx-2"
|
||||
@mouseenter="rotateGently(Math.random() * 2)"
|
||||
@mouseleave="rotateGently(Math.random() * 2)"
|
||||
>
|
||||
<h2 class="text-center text-heading-lg font-medium">
|
||||
Welcome, {{ activeUser?.name?.split(' ')[0] }}!
|
||||
</h2>
|
||||
<p class="text-center text-body-2xs text-foreground2">
|
||||
Let's get to know each other. What industry do you work in?
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-3 pt-2">
|
||||
<FormButton
|
||||
v-for="val in OnboardingIndustry"
|
||||
:key="val"
|
||||
class="text-xs hover:scale-[1.05] capitalize"
|
||||
size="sm"
|
||||
color="outline"
|
||||
full-width
|
||||
@click="setIndustry(val)"
|
||||
@mouseenter="rotateGently(Math.random() * 2)"
|
||||
@focus="rotateGently(Math.random() * 2)"
|
||||
>
|
||||
{{ val }}
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition
|
||||
enter-from-class="opacity-0"
|
||||
enter-active-class="transition duration-300"
|
||||
>
|
||||
<!-- eslint-disable-next-line vuejs-accessibility/mouse-events-have-key-events -->
|
||||
<div
|
||||
v-show="step === 1"
|
||||
class="bg-foundation border dark:border-neutral-800 text-foreground backdrop-blur shadow-lg rounded-xl p-4 space-y-2 absolute pointer-events-auto mx-2"
|
||||
@mouseenter="rotateGently(Math.random() * 2)"
|
||||
@mouseleave="rotateGently(Math.random() * 2)"
|
||||
>
|
||||
<h2 class="text-center text-heading-lg font-medium">Thanks!</h2>
|
||||
<p class="text-center text-body-2xs text-foreground2">
|
||||
Last thing! Please select the role that best describes you:
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-3 pt-2">
|
||||
<FormButton
|
||||
v-for="val in OnboardingRole"
|
||||
:key="val"
|
||||
class="text-xs hover:scale-[1.05]"
|
||||
size="sm"
|
||||
color="outline"
|
||||
full-width
|
||||
@click="setRole(val)"
|
||||
@mouseenter="rotateGently(Math.random() * 2)"
|
||||
@focus="rotateGently(Math.random() * 2)"
|
||||
>
|
||||
{{ RoleTitleMap[val] }}
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Vector3 } from 'three'
|
||||
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
|
||||
import {
|
||||
OnboardingIndustry,
|
||||
OnboardingRole,
|
||||
RoleTitleMap
|
||||
} from '~~/lib/auth/helpers/onboarding'
|
||||
import type { OnboardingState } from '~~/lib/auth/helpers/onboarding'
|
||||
import { useProcessOnboarding } from '~~/lib/auth/composables/onboarding'
|
||||
import { useCameraUtilities } from '~~/lib/viewer/composables/ui'
|
||||
import { useViewerTour } from '~/lib/viewer/composables/tour'
|
||||
|
||||
const { setMixpanelSegments } = useProcessOnboarding()
|
||||
const {
|
||||
setView,
|
||||
camera: { position, target }
|
||||
} = useCameraUtilities()
|
||||
|
||||
const onboardingState = ref<OnboardingState>({ industry: undefined, role: undefined })
|
||||
|
||||
const { activeUser } = useActiveUser()
|
||||
const tourState = useViewerTour()
|
||||
|
||||
const emit = defineEmits(['next'])
|
||||
|
||||
const step = ref(0)
|
||||
|
||||
function setIndustry(val: OnboardingIndustry) {
|
||||
onboardingState.value.industry = val
|
||||
step.value++
|
||||
nextView()
|
||||
}
|
||||
|
||||
function setRole(val: OnboardingRole) {
|
||||
onboardingState.value.role = val
|
||||
step.value++
|
||||
nextView()
|
||||
// NOTE: workaround for being able to view this in storybook
|
||||
if (activeUser.value?.id) setMixpanelSegments(onboardingState.value)
|
||||
tourState.showSegmentation.value = false
|
||||
emit('next')
|
||||
}
|
||||
|
||||
/** Hardcoded vec3s in Z up space */
|
||||
const camPos = [
|
||||
[23.86779, 82.9541, 29.05586, -27.41942, 37.72358, 29.05586, 0, 1],
|
||||
[23.86779, 82.9541, 29.05586, -27.41942, 37.72358, 29.05586, 0, 1],
|
||||
[27.22726, 2.10995, 27.98292, -27.31762, 36.15982, 27.98292, 0, 1],
|
||||
[-42.39747, 72.34078, 29.54059, -25.71981, 35.86063, 29.54059, 0, 1],
|
||||
[27.22726, 2.10995, 27.98292, -27.31762, 36.15982, 27.98292, 0, 1],
|
||||
[-25.89795, 12.51216, 59.41238, -21.55546, 37.76445, 32.52495, 0, 1]
|
||||
]
|
||||
|
||||
let flip = 1
|
||||
const rotateGently = (factor = 1) => {
|
||||
setView({ azimuth: (Math.PI / 12) * flip * factor, polar: 0 }, true)
|
||||
flip *= -1
|
||||
}
|
||||
|
||||
function nextView() {
|
||||
position.value = new Vector3(
|
||||
camPos[step.value][0],
|
||||
camPos[step.value][1],
|
||||
camPos[step.value][2]
|
||||
)
|
||||
target.value = new Vector3(
|
||||
camPos[step.value][3],
|
||||
camPos[step.value][4],
|
||||
camPos[step.value][5]
|
||||
)
|
||||
}
|
||||
</script>
|
||||
@@ -1,162 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
ref="parentEl"
|
||||
class="fixed z-30 left-0 top-0 w-screen h-[100dvh] pointer-events-none overflow-hidden"
|
||||
>
|
||||
<!--
|
||||
Tour Slideshow
|
||||
-->
|
||||
<TourComment
|
||||
v-for="(item, index) in slideshowItems.slice(0, tourItems.length)"
|
||||
:key="index"
|
||||
:item="item"
|
||||
:index="index"
|
||||
class="absolute"
|
||||
:class="isSmallerOrEqualSm ? 'bottom-0 left-0 w-screen' : ''"
|
||||
:style="isSmallerOrEqualSm ? undefined : item.style"
|
||||
:show-controls="item.showControls"
|
||||
:disable-next="hasAddedOverlay"
|
||||
@skip="finishSlideshow()"
|
||||
>
|
||||
<Component :is="tourItems[index]" @has-added-overlay="hasAddedOverlay = true" />
|
||||
</TourComment>
|
||||
<!-- In case the bubble is closed by the user, we need to display something -->
|
||||
<Transition
|
||||
enter-from-class="opacity-0"
|
||||
leave-to-class="opacity-0"
|
||||
enter-active-class="transition duration-300"
|
||||
leave-active-class="transition duration-300"
|
||||
>
|
||||
<div
|
||||
v-show="!hasOpenComments"
|
||||
class="fixed bottom-0 left-0 w-full h-28 flex align-center p-10 items-center justify-center space-x-2 pointer-events-auto"
|
||||
>
|
||||
<FormButton size="sm" color="outline" rounded @click="finishSlideshow()">
|
||||
Skip
|
||||
</FormButton>
|
||||
<FormButton
|
||||
size="lg"
|
||||
:icon-right="ArrowRightIcon"
|
||||
rounded
|
||||
class="shadow-md"
|
||||
@click="resumeSlideshow()"
|
||||
>
|
||||
Resume tour
|
||||
</FormButton>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
// Disclaimer, not the cleanest code.
|
||||
import type { Nullable } from '@speckle/shared'
|
||||
import type { Vector3 } from 'three'
|
||||
import { items as slideshowItemsRaw } from '~~/lib/tour/slideshowItems'
|
||||
import { ArrowRightIcon } from '@heroicons/vue/24/solid'
|
||||
import { useViewerAnchoredPoints } from '~~/lib/viewer/composables/anchorPoints'
|
||||
|
||||
// Slideshow component imports
|
||||
import FirstTip from '~~/components/tour/content/FirstTip.vue'
|
||||
import BasicViewerNavigation from '~~/components/tour/content/BasicViewerNavigation.vue'
|
||||
import OverlayModel from '~~/components/tour/content/OverlayModel.vue'
|
||||
import { useCameraUtilities } from '~~/lib/viewer/composables/ui'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
import { useViewerTour } from '~/lib/viewer/composables/tour'
|
||||
|
||||
const emit = defineEmits(['next'])
|
||||
|
||||
const tourStage = useViewerTour()
|
||||
const { zoom, setView } = useCameraUtilities()
|
||||
|
||||
// Drives the amount of slideshow items
|
||||
const tourItems = [FirstTip, BasicViewerNavigation, OverlayModel /* , LastTip */]
|
||||
|
||||
// Ensuring we don't have more 3d points than actual tips by slicing the array
|
||||
// TODO: should check the other way around, but since this part is so handcrafted
|
||||
// doesn't make much sense.
|
||||
const slideshowItems = ref(slideshowItemsRaw.slice(0, tourItems.length))
|
||||
provide('slideshowItems', slideshowItems)
|
||||
|
||||
const hasAddedOverlay = ref(false)
|
||||
const lastOpenIndex = ref(0)
|
||||
const mp = useMixpanel()
|
||||
|
||||
// const isSmallerOrEqualSm = computed(() => breakpoints.smallerOrEqual('sm').value)
|
||||
const { isSmallerOrEqualSm } = useIsSmallerOrEqualThanBreakpoint()
|
||||
|
||||
const next = (currentIndex: number) => {
|
||||
if (currentIndex + 1 >= slideshowItems.value.length) {
|
||||
finishSlideshow()
|
||||
return
|
||||
}
|
||||
slideshowItems.value[currentIndex].expanded = false
|
||||
slideshowItems.value[currentIndex].viewed = true
|
||||
slideshowItems.value[currentIndex + 1].expanded = true
|
||||
lastOpenIndex.value = currentIndex + 1
|
||||
mp.track('Onboarding Action', {
|
||||
type: 'action',
|
||||
name: 'slideshow',
|
||||
action: 'next',
|
||||
step: currentIndex
|
||||
})
|
||||
}
|
||||
|
||||
const prev = (currentIndex: number) => {
|
||||
if (currentIndex - 1 < 0) return
|
||||
slideshowItems.value[currentIndex].expanded = false
|
||||
slideshowItems.value[currentIndex - 1].expanded = true
|
||||
lastOpenIndex.value = currentIndex - 1
|
||||
mp.track('Onboarding Action', {
|
||||
type: 'action',
|
||||
name: 'slideshow',
|
||||
action: 'previous',
|
||||
step: currentIndex
|
||||
})
|
||||
}
|
||||
|
||||
const toggle = (index: number) => {
|
||||
if (!slideshowItems.value[index]) return
|
||||
slideshowItems.value[index].expanded = !slideshowItems.value[index].expanded
|
||||
if (slideshowItems.value[index].expanded) lastOpenIndex.value = index
|
||||
|
||||
mp.track('Onboarding Action', {
|
||||
type: 'action',
|
||||
name: 'slideshow',
|
||||
action: 'toggle',
|
||||
step: index
|
||||
})
|
||||
}
|
||||
|
||||
provide('slideshowActions', { next, prev, toggle })
|
||||
|
||||
const hasOpenComments = computed(() => {
|
||||
return slideshowItems.value.some((item) => item.expanded === true)
|
||||
})
|
||||
|
||||
const parentEl = ref(null as Nullable<HTMLElement>)
|
||||
useViewerAnchoredPoints({
|
||||
parentEl,
|
||||
points: slideshowItems,
|
||||
pointLocationGetter: (c) => c.location as Vector3,
|
||||
updatePositionCallback: (c, res) => {
|
||||
c.style = {
|
||||
...c.style,
|
||||
...res.style,
|
||||
display: 'inline-block',
|
||||
transition: 'all 0.1s ease'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const finishSlideshow = () => {
|
||||
zoom()
|
||||
setView('left')
|
||||
tourStage.showNavbar.value = true
|
||||
tourStage.showControls.value = true
|
||||
emit('next')
|
||||
}
|
||||
|
||||
const resumeSlideshow = () => {
|
||||
slideshowItems.value[lastOpenIndex.value].expanded = true
|
||||
}
|
||||
</script>
|
||||
@@ -1,78 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="isSmallerOrEqualSm">
|
||||
<p class="text-sm">
|
||||
<strong>Navigate</strong>
|
||||
easily with hand gestures:
|
||||
</p>
|
||||
<div class="flex items-center justify-between gap-4 py-3 text-xs">
|
||||
<div class="flex gap-1 items-center">
|
||||
<IconHandRotate class="h-5 w-5" />
|
||||
rotate
|
||||
</div>
|
||||
<div class="flex gap-1 items-center">
|
||||
<IconHandSelect class="h-5 w-5" />
|
||||
select
|
||||
</div>
|
||||
<div class="flex gap-1 items-center">
|
||||
<IconHandZoom class="h-5 w-5" />
|
||||
zoom
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<p class="text-sm">
|
||||
<strong>Navigate</strong>
|
||||
easily with your mouse:
|
||||
</p>
|
||||
<div class="flex items-center justify-between gap-4 py-3 text-xs">
|
||||
<div class="flex gap-1 items-center">
|
||||
<IconMouseRotate class="h-5 w-5" />
|
||||
rotate
|
||||
</div>
|
||||
<div class="flex gap-1 items-center">
|
||||
<IconMouseZoom class="h-5 w-5" />
|
||||
zoom
|
||||
</div>
|
||||
<div class="flex gap-1 items-center">
|
||||
<IconMousePan class="h-5 w-5" />
|
||||
pan
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm">
|
||||
<div v-if="hasMovedCamera" class="font-medium flex items-center">
|
||||
<CheckIcon class="w-4 h-4 text-success mr-2" />
|
||||
<p>{{ encouragements[controlEndCounts] }}</p>
|
||||
</div>
|
||||
<p v-else>Give it a try now!</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon } from '@heroicons/vue/24/solid'
|
||||
import { useIsSmallerOrEqualThanBreakpoint } from '~~/composables/browser'
|
||||
import { useViewerCameraControlEndTracker } from '~~/lib/viewer/composables/viewer'
|
||||
|
||||
const hasMovedCamera = ref(false)
|
||||
const controlEndCounts = ref(-1)
|
||||
const encouragements = [
|
||||
'Nicely done!',
|
||||
'Wow, you are a pro!',
|
||||
'3D is fun!',
|
||||
"Don't get dizzy!"
|
||||
]
|
||||
|
||||
const { isSmallerOrEqualSm } = useIsSmallerOrEqualThanBreakpoint()
|
||||
|
||||
useViewerCameraControlEndTracker(() => {
|
||||
hasMovedCamera.value = true
|
||||
if (controlEndCounts.value === encouragements.length - 1) {
|
||||
controlEndCounts.value = 0
|
||||
} else {
|
||||
controlEndCounts.value++
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,12 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<p class="text-sm">
|
||||
Let's run through a few fast tips! This is Speckle's 3D viewer, and what you're
|
||||
looking at is a
|
||||
<b>model.</b>
|
||||
<br />
|
||||
<br />
|
||||
Next, we're going to learn how to navigate it!
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,13 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<p class="text-sm">There's much more to Speckle.</p>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useViewerTour } from '~/lib/viewer/composables/tour'
|
||||
|
||||
const state = useViewerTour()
|
||||
|
||||
state.showNavbar.value = true
|
||||
state.showControls.value = true
|
||||
</script>
|
||||
@@ -1,72 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-show="!hasAddedOverlay">
|
||||
<p class="text-sm">
|
||||
Speckle allows you to load multiple models in the same viewer.
|
||||
</p>
|
||||
<p class="text-sm mt-3">
|
||||
<span v-show="!hasAddedOverlay">
|
||||
<FormButton
|
||||
color="outline"
|
||||
:icon-right="hasAddedOverlay ? CheckIcon : PlusIcon"
|
||||
:disabled="hasAddedOverlay"
|
||||
@click="addOverlay()"
|
||||
>
|
||||
Add another model
|
||||
</FormButton>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-show="hasAddedOverlay">
|
||||
<p class="text-sm">
|
||||
Nice - you've just created a "federated" model. Ready for what's next?
|
||||
<!-- <br />
|
||||
<br />
|
||||
Let's go to the next step. -->
|
||||
</p>
|
||||
<!-- <p class="text-sm">You can overlay as many models as you want.</p> -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, PlusIcon } from '@heroicons/vue/24/solid'
|
||||
import { SpeckleViewer } from '@speckle/shared'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { latestModelsQuery } from '~~/lib/projects/graphql/queries'
|
||||
import {
|
||||
useInjectedViewerRequestedResources,
|
||||
useInjectedViewerLoadedResources
|
||||
} from '~~/lib/viewer/composables/setup'
|
||||
|
||||
import { SECOND_MODEL_NAME } from '~~/lib/auth/composables/onboarding'
|
||||
|
||||
const emit = defineEmits(['hasAddedOverlay'])
|
||||
|
||||
const { items } = useInjectedViewerRequestedResources()
|
||||
const { project } = useInjectedViewerLoadedResources()
|
||||
const id = project.value?.id as string
|
||||
|
||||
const { result } = useQuery(latestModelsQuery, () => ({ projectId: id }))
|
||||
|
||||
const hasAddedOverlay = ref(false)
|
||||
async function addOverlay() {
|
||||
const models = result.value?.project?.models.items
|
||||
const otherModel = models?.find((m) => m.name === SECOND_MODEL_NAME)
|
||||
|
||||
if (otherModel)
|
||||
await items.update([
|
||||
...items.value,
|
||||
...SpeckleViewer.ViewerRoute.resourceBuilder()
|
||||
.addModel(otherModel?.id)
|
||||
.toResources()
|
||||
])
|
||||
|
||||
hasAddedOverlay.value = true
|
||||
emit('hasAddedOverlay')
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (hasAddedOverlay.value) return
|
||||
addOverlay()
|
||||
})
|
||||
</script>
|
||||
@@ -1,15 +1,9 @@
|
||||
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
|
||||
<template>
|
||||
<div v-if="showControls">
|
||||
<div>
|
||||
<div
|
||||
class="absolute z-20 flex max-h-screen simple-scrollbar flex-col space-y-1 md:space-y-2 px-2"
|
||||
:class="
|
||||
showNavbar && !isEmbedEnabled
|
||||
? 'pt-[3.8rem]'
|
||||
: isTransparent
|
||||
? 'pt-2'
|
||||
: 'pt-2 pb-16'
|
||||
"
|
||||
:class="!isEmbedEnabled ? 'pt-[3.8rem]' : isTransparent ? 'pt-2' : 'pt-2 pb-16'"
|
||||
>
|
||||
<!-- Models -->
|
||||
<ViewerControlsButtonToggle
|
||||
@@ -164,7 +158,7 @@
|
||||
<div
|
||||
v-if="activePanel !== 'none'"
|
||||
ref="resizeHandle"
|
||||
class="absolute z-10 max-h-[calc(100dvh-4rem)] w-7 mt-[3.9rem] hidden sm:flex group overflow-hidden items-center rounded-r cursor-ew-resize z-30"
|
||||
class="absolute max-h-[calc(100dvh-4rem)] w-7 mt-[3.9rem] hidden sm:flex group overflow-hidden items-center rounded-r cursor-ew-resize z-30"
|
||||
:style="`left:${width - 2}px; height:${height ? height - 10 : 0}px`"
|
||||
@mousedown="startResizing"
|
||||
>
|
||||
@@ -252,7 +246,6 @@
|
||||
<FormButton @click="resetSectionBox()">Reset section box</FormButton>
|
||||
</Portal>
|
||||
</div>
|
||||
<div v-else />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
@@ -278,7 +271,6 @@ import {
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
import { useIsSmallerOrEqualThanBreakpoint } from '~~/composables/browser'
|
||||
import { useEmbed } from '~/lib/viewer/composables/setup/embed'
|
||||
import { useViewerTour } from '~/lib/viewer/composables/tour'
|
||||
import {
|
||||
onKeyStroke,
|
||||
useEventListener,
|
||||
@@ -361,7 +353,6 @@ const {
|
||||
} = useSectionBoxUtilities()
|
||||
const { getActiveMeasurement, removeMeasurement, enableMeasurements } =
|
||||
useMeasurementUtilities()
|
||||
const { showNavbar, showControls } = useViewerTour()
|
||||
const { isTransparent, isEnabled: isEmbedEnabled } = useEmbed()
|
||||
const {
|
||||
zoomExtentsOrSelection,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div :class="`${loadProgress < 1 && viewerBusy ? 'mt-0' : '-mt-5'} transition-all`">
|
||||
<div
|
||||
:class="`absolute w-full max-w-screen flex justify-center ${
|
||||
showNavbar && !isEmbedEnabled ? 'mt-14' : 'mt-0'
|
||||
!isEmbedEnabled ? 'mt-14' : 'mt-0'
|
||||
} z-50`"
|
||||
>
|
||||
<div
|
||||
@@ -23,10 +23,7 @@
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useEmbed } from '~/lib/viewer/composables/setup/embed'
|
||||
import { useViewerTour } from '~/lib/viewer/composables/tour'
|
||||
import { useInjectedViewerInterfaceState } from '~~/lib/viewer/composables/setup'
|
||||
const { isEnabled: isEmbedEnabled } = useEmbed()
|
||||
|
||||
const { viewerBusy, loadProgress } = useInjectedViewerInterfaceState()
|
||||
const { showNavbar } = useViewerTour()
|
||||
</script>
|
||||
|
||||
@@ -27,13 +27,6 @@
|
||||
</Portal>
|
||||
|
||||
<ClientOnly>
|
||||
<!-- Tour host -->
|
||||
<div
|
||||
v-if="showTour"
|
||||
class="fixed w-full h-[100dvh] flex justify-center items-center pointer-events-none z-[100]"
|
||||
>
|
||||
<TourOnboarding @complete="showTour = false" />
|
||||
</div>
|
||||
<!-- Viewer host -->
|
||||
<div
|
||||
class="viewer special-gradient absolute z-10 overflow-hidden w-screen"
|
||||
@@ -50,7 +43,7 @@
|
||||
enter-from-class="opacity-0"
|
||||
enter-active-class="transition duration-1000"
|
||||
>
|
||||
<ViewerAnchoredPoints v-show="showControls" />
|
||||
<ViewerAnchoredPoints />
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
@@ -65,7 +58,7 @@
|
||||
enter-from-class="opacity-0"
|
||||
enter-active-class="transition duration-1000"
|
||||
>
|
||||
<ViewerControls v-show="showControls" class="relative z-20" />
|
||||
<ViewerControls class="relative z-20" />
|
||||
</Transition>
|
||||
|
||||
<!-- Viewer Object Selection Info Display -->
|
||||
@@ -74,9 +67,7 @@
|
||||
enter-from-class="opacity-0"
|
||||
enter-active-class="transition duration-1000"
|
||||
>
|
||||
<div v-show="showControls">
|
||||
<ViewerSelectionSidebar class="z-20" />
|
||||
</div>
|
||||
<ViewerSelectionSidebar class="z-20" />
|
||||
</Transition>
|
||||
<div
|
||||
class="absolute z-10 w-screen px-8 grid grid-cols-1 sm:grid-cols-3 gap-2"
|
||||
@@ -122,7 +113,6 @@ import {
|
||||
import dayjs from 'dayjs'
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
import { useEmbed } from '~/lib/viewer/composables/setup/embed'
|
||||
import { useViewerTour } from '~/lib/viewer/composables/tour'
|
||||
import { useFilterUtilities } from '~/lib/viewer/composables/ui'
|
||||
import { projectsRoute } from '~~/lib/common/helpers/route'
|
||||
import { workspaceRoute } from '~/lib/common/helpers/route'
|
||||
@@ -133,7 +123,6 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const { showTour, showControls } = useViewerTour()
|
||||
const isWorkspacesEnabled = useIsWorkspacesEnabled()
|
||||
|
||||
const modelId = computed(() => route.params.modelId as string)
|
||||
|
||||
@@ -1,30 +1,23 @@
|
||||
<template>
|
||||
<div class="bg-foundation-page">
|
||||
<nav class="fixed z-40 top-0 h-12 bg-foundation border-b border-outline-2">
|
||||
<div class="flex items-center justify-between h-full w-screen py-4 px-3 sm:px-4">
|
||||
<HeaderLogoBlock
|
||||
:active="false"
|
||||
class="min-w-40 cursor-pointer"
|
||||
no-link
|
||||
@click="onCancelClick"
|
||||
/>
|
||||
<FormButton size="sm" color="outline" @click="onCancelClick">Cancel</FormButton>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="h-dvh w-dvh overflow-hidden flex flex-col">
|
||||
<div class="h-12 w-full shrink-0" />
|
||||
<main class="w-full h-full overflow-y-auto simple-scrollbar pt-8 pb-16">
|
||||
<div class="container mx-auto px-6 md:px-12">
|
||||
<WorkspaceWizard :workspace-id="workspaceId" />
|
||||
<HeaderWithEmptyPage empty-header>
|
||||
<template #header-left>
|
||||
<HeaderLogoBlock
|
||||
:active="false"
|
||||
class="min-w-40 cursor-pointer"
|
||||
no-link
|
||||
@click="onCancelClick"
|
||||
/>
|
||||
</template>
|
||||
<template #header-right>
|
||||
<FormButton size="sm" color="outline" @click="onCancelClick">Cancel</FormButton>
|
||||
</template>
|
||||
|
||||
<WorkspaceWizardCancelDialog
|
||||
v-model:open="isCancelDialogOpen"
|
||||
:workspace-id="workspaceId"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<WorkspaceWizard :workspace-id="workspaceId" />
|
||||
<WorkspaceWizardCancelDialog
|
||||
v-model:open="isCancelDialogOpen"
|
||||
:workspace-id="workspaceId"
|
||||
/>
|
||||
</HeaderWithEmptyPage>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -42,6 +42,14 @@ export const useIsMultipleEmailsEnabled = () => {
|
||||
return ref(FF_MULTIPLE_EMAILS_MODULE_ENABLED)
|
||||
}
|
||||
|
||||
export const useIsOnboardingForced = () => {
|
||||
const {
|
||||
public: { FF_FORCE_ONBOARDING }
|
||||
} = useRuntimeConfig()
|
||||
|
||||
return ref(FF_FORCE_ONBOARDING)
|
||||
}
|
||||
|
||||
export const useIsGendoModuleEnabled = () => {
|
||||
const {
|
||||
public: { FF_GENDOAI_MODULE_ENABLED }
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<div class="w-screen h-[100dvh] overflow-hidden">
|
||||
<ClientOnly>
|
||||
<slot />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,28 +1,7 @@
|
||||
<template>
|
||||
<div class="relative min-h-full">
|
||||
<div
|
||||
v-if="debug"
|
||||
class="pointer-events-none fixed bottom-0 z-40 flex w-full space-x-2 p-3 text-xs"
|
||||
>
|
||||
<FormButton class="pointer-events-auto" size="sm" @click="toggleNavbar">
|
||||
nav
|
||||
</FormButton>
|
||||
<FormButton class="pointer-events-auto" size="sm" @click="toggleViewerControls">
|
||||
viewer ctrls
|
||||
</FormButton>
|
||||
<FormButton class="pointer-events-auto" size="sm" @click="toggleTour">
|
||||
tour ctrls
|
||||
</FormButton>
|
||||
<!-- <span>{{ tourState }}</span> -->
|
||||
</div>
|
||||
|
||||
<ClientOnly>
|
||||
<Transition
|
||||
enter-from-class="opacity-0"
|
||||
enter-active-class="transition duration-1000"
|
||||
>
|
||||
<HeaderNavBar v-show="showNavbar" class="relative z-20" />
|
||||
</Transition>
|
||||
<HeaderNavBar v-if="!isEmbedEnabled" class="relative z-20" />
|
||||
</ClientOnly>
|
||||
<main class="absolute top-0 left-0 z-10 h-[100dvh] w-screen">
|
||||
<slot />
|
||||
@@ -30,19 +9,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useViewerTour } from '~/lib/viewer/composables/tour'
|
||||
import { useEmbed } from '~/lib/viewer/composables/setup/embed'
|
||||
|
||||
const { showNavbar, showTour, showControls } = useViewerTour()
|
||||
|
||||
const debug = ref(false)
|
||||
|
||||
const toggleNavbar = () => {
|
||||
showNavbar.value = !showNavbar.value
|
||||
}
|
||||
const toggleTour = () => {
|
||||
showTour.value = !showTour.value
|
||||
}
|
||||
const toggleViewerControls = () => {
|
||||
showControls.value = !showControls.value
|
||||
}
|
||||
const { isEnabled: isEmbedEnabled } = useEmbed()
|
||||
</script>
|
||||
|
||||
@@ -11,6 +11,10 @@ export const activeUserQuery = graphql(`
|
||||
activeUser {
|
||||
id
|
||||
email
|
||||
emails {
|
||||
id
|
||||
verified
|
||||
}
|
||||
company
|
||||
bio
|
||||
name
|
||||
|
||||
@@ -303,12 +303,6 @@ export const useAuthManager = (
|
||||
skipRedirect: postAuthRedirect.hadPendingRedirect.value
|
||||
})
|
||||
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: 'Welcome!',
|
||||
description: "You've been successfully authenticated"
|
||||
})
|
||||
|
||||
postAuthRedirect.popAndFollowRedirect()
|
||||
} catch (e) {
|
||||
triggerNotification({
|
||||
@@ -378,6 +372,9 @@ export const useAuthManager = (
|
||||
newsletter
|
||||
})
|
||||
|
||||
const registeredThisSession = useRegisteredThisSession()
|
||||
registeredThisSession.value = true
|
||||
|
||||
// eslint-disable-next-line camelcase
|
||||
goHome({ query: { access_code: accessCode } })
|
||||
}
|
||||
@@ -480,6 +477,12 @@ const useAuthAppIdAndChallenge = () => {
|
||||
return { appId, challenge }
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether the user just completed registration
|
||||
*/
|
||||
export const useRegisteredThisSession = () =>
|
||||
useState<boolean>('registered-this-session', () => false)
|
||||
|
||||
export const useLoginOrRegisterUtils = () => {
|
||||
const appIdAndChallenge = useAuthAppIdAndChallenge()
|
||||
const route = useRoute()
|
||||
|
||||
@@ -15,26 +15,20 @@ import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables
|
||||
import { useNavigateToHome } from '~~/lib/common/helpers/route'
|
||||
import { projectsDashboardQuery } from '~~/lib/projects/graphql/queries'
|
||||
|
||||
const ONBOARDING_PROP_INDUSTRY = 'onboarding_v1_industry'
|
||||
const ONBOARDING_PROP_ROLE = 'onboarding_v1_role'
|
||||
|
||||
export const FIRST_MODEL_NAME = 'base design'
|
||||
export const SECOND_MODEL_NAME = 'building wrapper'
|
||||
|
||||
export function useProcessOnboarding() {
|
||||
export const useProcessOnboarding = () => {
|
||||
const mixpanel = useMixpanel()
|
||||
const { distinctId, activeUser } = useActiveUser()
|
||||
const apollo = useApolloClient().client
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
const goHome = useNavigateToHome()
|
||||
|
||||
/**
|
||||
* Sends to mp the segmentation info (industry, role)
|
||||
* @param state
|
||||
*/
|
||||
const setMixpanelSegments = (state: OnboardingState) => {
|
||||
mixpanel.people.set_once(ONBOARDING_PROP_INDUSTRY, state.industry || null)
|
||||
mixpanel.people.set_once(ONBOARDING_PROP_ROLE, state.role || null)
|
||||
const setMixpanelSegments = (segments: Partial<OnboardingState>) => {
|
||||
mixpanel.people.set(segments)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,7 +124,6 @@ export function useProcessOnboarding() {
|
||||
throw new OnboardingError('Attempting to onboard unidentified user')
|
||||
|
||||
// Send data to mixpanel
|
||||
mixpanel.people.set_once(ONBOARDING_PROP_INDUSTRY, state.industry || null)
|
||||
mixpanel.people.set_once(ONBOARDING_PROP_ROLE, state.role || null)
|
||||
|
||||
// Mark onboarding as finished
|
||||
|
||||
@@ -1,31 +1,66 @@
|
||||
export enum OnboardingIndustry {
|
||||
Architecture = 'architecture & planning',
|
||||
Engineering = 'engineering',
|
||||
Construction = 'construction',
|
||||
Software = 'software development',
|
||||
Edu = 'higher education',
|
||||
export enum OnboardingRole {
|
||||
ComputationalDesign = 'computational-design',
|
||||
BIM = 'bim',
|
||||
ArchitecturePlanning = 'architecture-planning',
|
||||
EngineeringAEC = 'engineering-aec',
|
||||
EngineeringSoftware = 'engineering-software',
|
||||
Education = 'education',
|
||||
Management = 'management',
|
||||
Other = 'other'
|
||||
}
|
||||
|
||||
export enum OnboardingRole {
|
||||
ComputationalDesigner = 'computational-designer',
|
||||
SoftwareDeveloper = 'software-developer',
|
||||
DesignerOrEngineer = 'designer-or-engineer',
|
||||
Manager = 'manager',
|
||||
Student = 'student',
|
||||
export enum OnboardingPlan {
|
||||
Exploring = 'exploring',
|
||||
DataExchange = 'data-exchange',
|
||||
Analytics = 'analytics',
|
||||
Collaboration = 'collaboration',
|
||||
DataWarehouse = 'data-warehouse',
|
||||
Development = 'development',
|
||||
Other = 'other'
|
||||
}
|
||||
|
||||
export enum OnboardingSource {
|
||||
SocialMedia = 'social-media',
|
||||
Search = 'internet-search',
|
||||
Referral = 'friend-or-colleague',
|
||||
Event = 'event-conference',
|
||||
Education = 'university-course',
|
||||
Other = 'other'
|
||||
}
|
||||
|
||||
export const RoleTitleMap: Record<OnboardingRole, string> = {
|
||||
[OnboardingRole.ComputationalDesigner]: 'Computational Designer',
|
||||
[OnboardingRole.SoftwareDeveloper]: 'Software Developer',
|
||||
[OnboardingRole.DesignerOrEngineer]: 'Designer Or Engineer',
|
||||
[OnboardingRole.Manager]: 'Manager',
|
||||
[OnboardingRole.Student]: 'Student',
|
||||
[OnboardingRole.ComputationalDesign]: 'Computational Design',
|
||||
[OnboardingRole.BIM]: 'Building Information Modelling (BIM)',
|
||||
[OnboardingRole.ArchitecturePlanning]: 'Architecture & Planning',
|
||||
[OnboardingRole.EngineeringAEC]: 'Engineering (Structural, MEP, Civil, etc)',
|
||||
[OnboardingRole.EngineeringSoftware]: 'Engineering (Software)',
|
||||
[OnboardingRole.Education]: 'Education',
|
||||
[OnboardingRole.Management]: 'Management & Leadership',
|
||||
[OnboardingRole.Other]: 'Other'
|
||||
}
|
||||
|
||||
export type OnboardingState = {
|
||||
industry?: OnboardingIndustry
|
||||
role?: OnboardingRole
|
||||
export const PlanTitleMap: Record<OnboardingPlan, string> = {
|
||||
[OnboardingPlan.Exploring]: 'Just checking things out',
|
||||
[OnboardingPlan.DataExchange]: 'Exchange data between applications',
|
||||
[OnboardingPlan.Analytics]:
|
||||
'Data analytics, visualisation and reporting (eg PowerBI)',
|
||||
[OnboardingPlan.Collaboration]: 'Collaborate with my team and share 3D models online',
|
||||
[OnboardingPlan.DataWarehouse]: 'Data warehouse and common data environment (CDE)',
|
||||
[OnboardingPlan.Development]: 'Develop custom functionalities and apps',
|
||||
[OnboardingPlan.Other]: 'Other'
|
||||
}
|
||||
|
||||
export const SourceTitleMap: Record<OnboardingSource, string> = {
|
||||
[OnboardingSource.SocialMedia]: 'Social Media',
|
||||
[OnboardingSource.Search]: 'Internet search',
|
||||
[OnboardingSource.Referral]: 'Friend or colleague',
|
||||
[OnboardingSource.Event]: 'Event or conference',
|
||||
[OnboardingSource.Education]: 'University or course',
|
||||
[OnboardingSource.Other]: 'Other'
|
||||
}
|
||||
|
||||
export type OnboardingState = {
|
||||
role?: OnboardingRole
|
||||
plans?: OnboardingPlan[]
|
||||
source?: OnboardingSource
|
||||
}
|
||||
|
||||
@@ -17,8 +17,6 @@ const documents = {
|
||||
"\n fragment AuthLoginWithEmailBlock_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n email\n user {\n id\n }\n }\n": types.AuthLoginWithEmailBlock_PendingWorkspaceCollaboratorFragmentDoc,
|
||||
"\n query AuthRegisterPanelWorkspaceInvite($token: String) {\n workspaceInvite(token: $token) {\n id\n ...AuthWorkspaceInviteHeader_PendingWorkspaceCollaborator\n }\n }\n": types.AuthRegisterPanelWorkspaceInviteDocument,
|
||||
"\n fragment ServerTermsOfServicePrivacyPolicyFragment on ServerInfo {\n termsOfService\n }\n": types.ServerTermsOfServicePrivacyPolicyFragmentFragmentDoc,
|
||||
"\n query EmailVerificationBannerState {\n activeUser {\n id\n email\n verified\n hasPendingVerification\n }\n }\n": types.EmailVerificationBannerStateDocument,
|
||||
"\n mutation RequestVerification {\n requestVerification\n }\n": types.RequestVerificationDocument,
|
||||
"\n fragment AuthWorkspaceInviteHeader_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n workspaceName\n email\n user {\n id\n ...LimitedUserAvatar\n }\n }\n": types.AuthWorkspaceInviteHeader_PendingWorkspaceCollaboratorFragmentDoc,
|
||||
"\n fragment AuthSsoLogin_Workspace on LimitedWorkspace {\n id\n slug\n name\n logo\n }\n": types.AuthSsoLogin_WorkspaceFragmentDoc,
|
||||
"\n fragment AuthStategiesServerInfoFragment on ServerInfo {\n authStrategies {\n id\n name\n url\n }\n ...AuthThirdPartyLoginButtonOIDC_ServerInfo\n }\n": types.AuthStategiesServerInfoFragmentFragmentDoc,
|
||||
@@ -114,7 +112,6 @@ const documents = {
|
||||
"\n fragment SettingsServerRegionsTable_ServerRegionItem on ServerRegionItem {\n id\n name\n key\n description\n }\n": types.SettingsServerRegionsTable_ServerRegionItemFragmentDoc,
|
||||
"\n fragment SettingsSharedDeleteUserDialog_Workspace on Workspace {\n id\n plan {\n status\n name\n }\n subscription {\n currentBillingCycleEnd\n seats {\n guest\n plan\n }\n }\n }\n": types.SettingsSharedDeleteUserDialog_WorkspaceFragmentDoc,
|
||||
"\n fragment SettingsSharedProjects_Project on Project {\n ...ProjectsDeleteDialog_Project\n id\n name\n visibility\n createdAt\n updatedAt\n models(limit: 0) {\n totalCount\n }\n versions(limit: 0) {\n totalCount\n }\n team {\n id\n user {\n name\n id\n avatar\n }\n }\n }\n": types.SettingsSharedProjects_ProjectFragmentDoc,
|
||||
"\n fragment SettingsUserEmailCards_UserEmail on UserEmail {\n email\n id\n primary\n verified\n }\n": types.SettingsUserEmailCards_UserEmailFragmentDoc,
|
||||
"\n fragment SettingsUserProfileChangePassword_User on User {\n id\n email\n }\n": types.SettingsUserProfileChangePassword_UserFragmentDoc,
|
||||
"\n fragment SettingsUserProfileDeleteAccount_User on User {\n id\n email\n }\n": types.SettingsUserProfileDeleteAccount_UserFragmentDoc,
|
||||
"\n fragment SettingsUserProfileDetails_User on User {\n id\n name\n company\n ...UserProfileEditDialogAvatar_User\n }\n": types.SettingsUserProfileDetails_UserFragmentDoc,
|
||||
@@ -154,7 +151,7 @@ const documents = {
|
||||
"\n fragment WorkspaceSidebar_Workspace on Workspace {\n ...WorkspaceDashboardAbout_Workspace\n ...WorkspaceTeam_Workspace\n ...WorkspaceSecurity_Workspace\n slug\n plan {\n status\n }\n }\n": types.WorkspaceSidebar_WorkspaceFragmentDoc,
|
||||
"\n fragment WorkspaceWizard_Workspace on Workspace {\n creationState {\n completed\n state\n }\n name\n slug\n }\n": types.WorkspaceWizard_WorkspaceFragmentDoc,
|
||||
"\n fragment WorkspaceWizardStepRegion_ServerInfo on ServerInfo {\n multiRegion {\n regions {\n id\n ...SettingsWorkspacesRegionsSelect_ServerRegionItem\n }\n }\n }\n": types.WorkspaceWizardStepRegion_ServerInfoFragmentDoc,
|
||||
"\n query ActiveUserMainMetadata {\n activeUser {\n id\n email\n company\n bio\n name\n role\n avatar\n isOnboardingFinished\n createdAt\n verified\n notificationPreferences\n versions(limit: 0) {\n totalCount\n }\n }\n }\n": types.ActiveUserMainMetadataDocument,
|
||||
"\n query ActiveUserMainMetadata {\n activeUser {\n id\n email\n emails {\n id\n verified\n }\n company\n bio\n name\n role\n avatar\n isOnboardingFinished\n createdAt\n verified\n notificationPreferences\n versions(limit: 0) {\n totalCount\n }\n }\n }\n": types.ActiveUserMainMetadataDocument,
|
||||
"\n mutation CreateOnboardingProject {\n projectMutations {\n createForOnboarding {\n ...ProjectPageProject\n ...ProjectDashboardItem\n }\n }\n }\n ": types.CreateOnboardingProjectDocument,
|
||||
"\n mutation FinishOnboarding {\n activeUserMutations {\n finishOnboarding\n }\n }\n": types.FinishOnboardingDocument,
|
||||
"\n mutation RequestVerificationByEmail($email: String!) {\n requestVerificationByEmail(email: $email)\n }\n": types.RequestVerificationByEmailDocument,
|
||||
@@ -206,6 +203,7 @@ const documents = {
|
||||
"\n query InviteUserSearch($input: UsersRetrievalInput!) {\n users(input: $input) {\n items {\n id\n name\n avatar\n }\n }\n }\n": types.InviteUserSearchDocument,
|
||||
"\n mutation CreateNewRegion($input: CreateServerRegionInput!) {\n serverInfoMutations {\n multiRegion {\n create(input: $input) {\n id\n ...SettingsServerRegionsAddEditDialog_ServerRegionItem\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n }\n }\n }\n": types.CreateNewRegionDocument,
|
||||
"\n mutation UpdateRegion($input: UpdateServerRegionInput!) {\n serverInfoMutations {\n multiRegion {\n update(input: $input) {\n id\n ...SettingsServerRegionsAddEditDialog_ServerRegionItem\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n }\n }\n }\n": types.UpdateRegionDocument,
|
||||
"\n query PagesOnboardingDiscoverableWorkspaces_ActiveUser {\n activeUser {\n id\n ...PagesOnboarding_DiscoverableWorkspaces\n }\n }\n": types.PagesOnboardingDiscoverableWorkspaces_ActiveUserDocument,
|
||||
"\n fragment ProjectPageTeamInternals_Project on Project {\n id\n role\n invitedTeam {\n id\n title\n role\n inviteId\n user {\n role\n ...LimitedUserAvatar\n }\n }\n team {\n role\n user {\n id\n role\n ...LimitedUserAvatar\n }\n }\n }\n": types.ProjectPageTeamInternals_ProjectFragmentDoc,
|
||||
"\n fragment ProjectPageTeamInternals_Workspace on Workspace {\n id\n team {\n items {\n id\n role\n user {\n id\n }\n }\n }\n }\n": types.ProjectPageTeamInternals_WorkspaceFragmentDoc,
|
||||
"\n fragment ProjectDashboardItemNoModels on Project {\n id\n name\n createdAt\n updatedAt\n role\n team {\n id\n user {\n id\n name\n avatar\n }\n }\n ...ProjectPageModelsCardProject\n }\n": types.ProjectDashboardItemNoModelsFragmentDoc,
|
||||
@@ -292,9 +290,9 @@ const documents = {
|
||||
"\n fragment AddDomainWorkspace on Workspace {\n slug\n }\n ": types.AddDomainWorkspaceFragmentDoc,
|
||||
"\n fragment SettingsMenu_Workspace on Workspace {\n id\n sso {\n provider {\n id\n }\n session {\n validUntil\n }\n }\n }\n": types.SettingsMenu_WorkspaceFragmentDoc,
|
||||
"\n mutation SettingsUpdateWorkspace($input: WorkspaceUpdateInput!) {\n workspaceMutations {\n update(input: $input) {\n ...SettingsWorkspacesGeneral_Workspace\n }\n }\n }\n": types.SettingsUpdateWorkspaceDocument,
|
||||
"\n mutation SettingsCreateUserEmail($input: CreateUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n create(input: $input) {\n ...SettingsUserEmails_User\n }\n }\n }\n }\n": types.SettingsCreateUserEmailDocument,
|
||||
"\n mutation SettingsDeleteUserEmail($input: DeleteUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n delete(input: $input) {\n ...SettingsUserEmails_User\n }\n }\n }\n }\n": types.SettingsDeleteUserEmailDocument,
|
||||
"\n mutation SettingsSetPrimaryUserEmail($input: SetPrimaryUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n setPrimary(input: $input) {\n ...SettingsUserEmails_User\n }\n }\n }\n }\n": types.SettingsSetPrimaryUserEmailDocument,
|
||||
"\n mutation SettingsCreateUserEmail($input: CreateUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n create(input: $input) {\n id\n emails {\n ...EmailFields\n }\n }\n }\n }\n }\n": types.SettingsCreateUserEmailDocument,
|
||||
"\n mutation SettingsDeleteUserEmail($input: DeleteUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n delete(input: $input) {\n id\n emails {\n ...EmailFields\n }\n }\n }\n }\n }\n": types.SettingsDeleteUserEmailDocument,
|
||||
"\n mutation SettingsSetPrimaryUserEmail($input: SetPrimaryUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n setPrimary(input: $input) {\n id\n emails {\n ...EmailFields\n }\n }\n }\n }\n }\n": types.SettingsSetPrimaryUserEmailDocument,
|
||||
"\n mutation SettingsNewEmailVerification($input: EmailVerificationRequestInput!) {\n activeUserMutations {\n emailMutations {\n requestNewEmailVerification(input: $input)\n }\n }\n }\n": types.SettingsNewEmailVerificationDocument,
|
||||
"\n mutation SettingsUpdateWorkspaceSecurity($input: WorkspaceUpdateInput!) {\n workspaceMutations {\n update(input: $input) {\n id\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n }\n }\n }\n": types.SettingsUpdateWorkspaceSecurityDocument,
|
||||
"\n mutation SettingsDeleteWorkspace($workspaceId: String!) {\n workspaceMutations {\n delete(workspaceId: $workspaceId)\n }\n }\n": types.SettingsDeleteWorkspaceDocument,
|
||||
@@ -314,7 +312,6 @@ const documents = {
|
||||
"\n query SettingsWorkspacesMembersSearch($slug: String!, $filter: WorkspaceTeamFilter) {\n workspaceBySlug(slug: $slug) {\n id\n team(filter: $filter) {\n items {\n id\n ...SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator\n }\n }\n }\n }\n": types.SettingsWorkspacesMembersSearchDocument,
|
||||
"\n query SettingsWorkspacesJoinRequestsSearch(\n $slug: String!\n $joinRequestsFilter: AdminWorkspaceJoinRequestFilter\n ) {\n workspaceBySlug(slug: $slug) {\n id\n ...SettingsWorkspacesMembersRequestsTable_Workspace\n }\n }\n": types.SettingsWorkspacesJoinRequestsSearchDocument,
|
||||
"\n query SettingsWorkspacesInvitesSearch(\n $slug: String!\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n ) {\n workspaceBySlug(slug: $slug) {\n ...SettingsWorkspacesMembersInvitesTable_Workspace\n }\n }\n": types.SettingsWorkspacesInvitesSearchDocument,
|
||||
"\n query SettingsUserEmailsQuery {\n activeUser {\n ...SettingsUserEmails_User\n }\n }\n": types.SettingsUserEmailsQueryDocument,
|
||||
"\n query SettingsWorkspacesProjects(\n $slug: String!\n $limit: Int!\n $cursor: String\n $filter: WorkspaceProjectsFilter\n ) {\n workspaceBySlug(slug: $slug) {\n id\n slug\n readOnly\n projects(limit: $limit, cursor: $cursor, filter: $filter) {\n cursor\n ...SettingsWorkspacesProjects_ProjectCollection\n }\n }\n }\n": types.SettingsWorkspacesProjectsDocument,
|
||||
"\n query SettingsWorkspaceSecurity($slug: String!) {\n workspaceBySlug(slug: $slug) {\n ...SettingsWorkspacesSecurity_Workspace\n }\n activeUser {\n ...SettingsWorkspacesSecurity_User\n }\n }\n": types.SettingsWorkspaceSecurityDocument,
|
||||
"\n fragment AppAuthorAvatar on AppAuthor {\n id\n name\n avatar\n }\n": types.AppAuthorAvatarFragmentDoc,
|
||||
@@ -323,6 +320,9 @@ const documents = {
|
||||
"\n mutation UpdateUser($input: UserUpdateInput!) {\n activeUserMutations {\n update(user: $input) {\n id\n name\n bio\n company\n avatar\n }\n }\n }\n": types.UpdateUserDocument,
|
||||
"\n mutation UpdateNotificationPreferences($input: JSONObject!) {\n userNotificationPreferencesUpdate(preferences: $input)\n }\n": types.UpdateNotificationPreferencesDocument,
|
||||
"\n mutation DeleteAccount($input: UserDeleteInput!) {\n userDelete(userConfirmation: $input)\n }\n": types.DeleteAccountDocument,
|
||||
"\n mutation verifyEmail($input: VerifyUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n verify(input: $input)\n }\n }\n }\n": types.VerifyEmailDocument,
|
||||
"\n fragment EmailFields on UserEmail {\n id\n email\n verified\n primary\n userId\n }\n": types.EmailFieldsFragmentDoc,
|
||||
"\n query UserEmails {\n activeUser {\n id\n emails {\n ...EmailFields\n }\n hasPendingVerification\n }\n }\n": types.UserEmailsDocument,
|
||||
"\n fragment ViewerCommentBubblesData on Comment {\n id\n viewedAt\n viewerState\n }\n": types.ViewerCommentBubblesDataFragmentDoc,
|
||||
"\n fragment ViewerCommentThread on Comment {\n ...ViewerCommentsListItem\n ...ViewerCommentBubblesData\n ...ViewerCommentsReplyItem\n }\n": types.ViewerCommentThreadFragmentDoc,
|
||||
"\n fragment ViewerCommentsReplyItem on Comment {\n id\n archived\n rawText\n text {\n doc\n }\n author {\n ...LimitedUserAvatar\n }\n createdAt\n ...ThreadCommentAttachment\n }\n": types.ViewerCommentsReplyItemFragmentDoc,
|
||||
@@ -384,13 +384,13 @@ const documents = {
|
||||
"\n fragment AutomateFunctionPage_AutomateFunction on AutomateFunction {\n id\n name\n description\n logo\n supportedSourceApps\n tags\n ...AutomateFunctionPageHeader_Function\n ...AutomateFunctionPageInfo_AutomateFunction\n ...AutomateAutomationCreateDialog_AutomateFunction\n creator {\n id\n }\n }\n": types.AutomateFunctionPage_AutomateFunctionFragmentDoc,
|
||||
"\n query AutomateFunctionPage($functionId: ID!) {\n automateFunction(id: $functionId) {\n ...AutomateFunctionPage_AutomateFunction\n }\n activeUser {\n workspaces {\n items {\n ...AutomateFunctionCreateDialog_Workspace\n ...AutomateFunctionEditDialog_Workspace\n }\n }\n }\n }\n": types.AutomateFunctionPageDocument,
|
||||
"\n query AutomateFunctionPageWorkspace($workspaceId: String!) {\n workspace(id: $workspaceId) {\n id\n ...AutomateFunctionPageHeader_Workspace\n }\n }\n": types.AutomateFunctionPageWorkspaceDocument,
|
||||
"\n fragment PagesOnboarding_DiscoverableWorkspaces on User {\n discoverableWorkspaces {\n id\n name\n logo\n description\n slug\n }\n }\n": types.PagesOnboarding_DiscoverableWorkspacesFragmentDoc,
|
||||
"\n fragment ProjectPageProject on Project {\n id\n createdAt\n modelCount: models(limit: 0) {\n totalCount\n }\n commentThreadCount: commentThreads(limit: 0) {\n totalCount\n }\n workspace {\n id\n }\n ...ProjectPageTeamInternals_Project\n ...ProjectPageProjectHeader\n ...ProjectPageTeamDialog\n ...ProjectsMoveToWorkspaceDialog_Project\n }\n": types.ProjectPageProjectFragmentDoc,
|
||||
"\n fragment ProjectPageAutomationPage_Automation on Automation {\n id\n ...ProjectPageAutomationHeader_Automation\n ...ProjectPageAutomationFunctions_Automation\n ...ProjectPageAutomationRuns_Automation\n }\n": types.ProjectPageAutomationPage_AutomationFragmentDoc,
|
||||
"\n fragment ProjectPageAutomationPage_Project on Project {\n id\n workspaceId\n ...ProjectPageAutomationHeader_Project\n }\n": types.ProjectPageAutomationPage_ProjectFragmentDoc,
|
||||
"\n fragment ProjectPageSettingsTab_Project on Project {\n id\n role\n }\n": types.ProjectPageSettingsTab_ProjectFragmentDoc,
|
||||
"\n fragment SettingsServerProjects_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...SettingsSharedProjects_Project\n }\n }\n": types.SettingsServerProjects_ProjectCollectionFragmentDoc,
|
||||
"\n query SettingsServerRegions {\n serverInfo {\n multiRegion {\n regions {\n id\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n availableKeys\n }\n }\n }\n": types.SettingsServerRegionsDocument,
|
||||
"\n fragment SettingsUserEmails_User on User {\n id\n emails {\n ...SettingsUserEmailCards_UserEmail\n }\n }\n": types.SettingsUserEmails_UserFragmentDoc,
|
||||
"\n fragment SettingsWorkspacesBilling_Workspace on Workspace {\n ...BillingAlert_Workspace\n id\n role\n plan {\n name\n status\n createdAt\n paymentMethod\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n seats {\n guest\n plan\n }\n }\n team {\n items {\n id\n role\n }\n }\n }\n": types.SettingsWorkspacesBilling_WorkspaceFragmentDoc,
|
||||
"\n fragment SettingsWorkspacesGeneral_Workspace on Workspace {\n ...SettingsWorkspacesGeneralEditAvatar_Workspace\n ...SettingsWorkspaceGeneralDeleteDialog_Workspace\n ...SettingsWorkspacesGeneralEditSlugDialog_Workspace\n id\n name\n slug\n description\n logo\n role\n defaultProjectRole\n plan {\n status\n name\n }\n }\n": types.SettingsWorkspacesGeneral_WorkspaceFragmentDoc,
|
||||
"\n fragment SettingsWorkspacesMembers_Workspace on Workspace {\n id\n role\n team {\n items {\n id\n }\n }\n invitedTeam(filter: $invitesFilter) {\n user {\n id\n }\n }\n adminWorkspacesJoinRequests(filter: $joinRequestsFilter) {\n totalCount\n }\n }\n": types.SettingsWorkspacesMembers_WorkspaceFragmentDoc,
|
||||
@@ -426,14 +426,6 @@ export function graphql(source: "\n query AuthRegisterPanelWorkspaceInvite($tok
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment ServerTermsOfServicePrivacyPolicyFragment on ServerInfo {\n termsOfService\n }\n"): (typeof documents)["\n fragment ServerTermsOfServicePrivacyPolicyFragment on ServerInfo {\n termsOfService\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query EmailVerificationBannerState {\n activeUser {\n id\n email\n verified\n hasPendingVerification\n }\n }\n"): (typeof documents)["\n query EmailVerificationBannerState {\n activeUser {\n id\n email\n verified\n hasPendingVerification\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n mutation RequestVerification {\n requestVerification\n }\n"): (typeof documents)["\n mutation RequestVerification {\n requestVerification\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -814,10 +806,6 @@ export function graphql(source: "\n fragment SettingsSharedDeleteUserDialog_Wor
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment SettingsSharedProjects_Project on Project {\n ...ProjectsDeleteDialog_Project\n id\n name\n visibility\n createdAt\n updatedAt\n models(limit: 0) {\n totalCount\n }\n versions(limit: 0) {\n totalCount\n }\n team {\n id\n user {\n name\n id\n avatar\n }\n }\n }\n"): (typeof documents)["\n fragment SettingsSharedProjects_Project on Project {\n ...ProjectsDeleteDialog_Project\n id\n name\n visibility\n createdAt\n updatedAt\n models(limit: 0) {\n totalCount\n }\n versions(limit: 0) {\n totalCount\n }\n team {\n id\n user {\n name\n id\n avatar\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment SettingsUserEmailCards_UserEmail on UserEmail {\n email\n id\n primary\n verified\n }\n"): (typeof documents)["\n fragment SettingsUserEmailCards_UserEmail on UserEmail {\n email\n id\n primary\n verified\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -977,7 +965,7 @@ export function graphql(source: "\n fragment WorkspaceWizardStepRegion_ServerIn
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query ActiveUserMainMetadata {\n activeUser {\n id\n email\n company\n bio\n name\n role\n avatar\n isOnboardingFinished\n createdAt\n verified\n notificationPreferences\n versions(limit: 0) {\n totalCount\n }\n }\n }\n"): (typeof documents)["\n query ActiveUserMainMetadata {\n activeUser {\n id\n email\n company\n bio\n name\n role\n avatar\n isOnboardingFinished\n createdAt\n verified\n notificationPreferences\n versions(limit: 0) {\n totalCount\n }\n }\n }\n"];
|
||||
export function graphql(source: "\n query ActiveUserMainMetadata {\n activeUser {\n id\n email\n emails {\n id\n verified\n }\n company\n bio\n name\n role\n avatar\n isOnboardingFinished\n createdAt\n verified\n notificationPreferences\n versions(limit: 0) {\n totalCount\n }\n }\n }\n"): (typeof documents)["\n query ActiveUserMainMetadata {\n activeUser {\n id\n email\n emails {\n id\n verified\n }\n company\n bio\n name\n role\n avatar\n isOnboardingFinished\n createdAt\n verified\n notificationPreferences\n versions(limit: 0) {\n totalCount\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -1182,6 +1170,10 @@ export function graphql(source: "\n mutation CreateNewRegion($input: CreateServ
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n mutation UpdateRegion($input: UpdateServerRegionInput!) {\n serverInfoMutations {\n multiRegion {\n update(input: $input) {\n id\n ...SettingsServerRegionsAddEditDialog_ServerRegionItem\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n }\n }\n }\n"): (typeof documents)["\n mutation UpdateRegion($input: UpdateServerRegionInput!) {\n serverInfoMutations {\n multiRegion {\n update(input: $input) {\n id\n ...SettingsServerRegionsAddEditDialog_ServerRegionItem\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query PagesOnboardingDiscoverableWorkspaces_ActiveUser {\n activeUser {\n id\n ...PagesOnboarding_DiscoverableWorkspaces\n }\n }\n"): (typeof documents)["\n query PagesOnboardingDiscoverableWorkspaces_ActiveUser {\n activeUser {\n id\n ...PagesOnboarding_DiscoverableWorkspaces\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -1529,15 +1521,15 @@ export function graphql(source: "\n mutation SettingsUpdateWorkspace($input: Wo
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n mutation SettingsCreateUserEmail($input: CreateUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n create(input: $input) {\n ...SettingsUserEmails_User\n }\n }\n }\n }\n"): (typeof documents)["\n mutation SettingsCreateUserEmail($input: CreateUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n create(input: $input) {\n ...SettingsUserEmails_User\n }\n }\n }\n }\n"];
|
||||
export function graphql(source: "\n mutation SettingsCreateUserEmail($input: CreateUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n create(input: $input) {\n id\n emails {\n ...EmailFields\n }\n }\n }\n }\n }\n"): (typeof documents)["\n mutation SettingsCreateUserEmail($input: CreateUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n create(input: $input) {\n id\n emails {\n ...EmailFields\n }\n }\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n mutation SettingsDeleteUserEmail($input: DeleteUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n delete(input: $input) {\n ...SettingsUserEmails_User\n }\n }\n }\n }\n"): (typeof documents)["\n mutation SettingsDeleteUserEmail($input: DeleteUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n delete(input: $input) {\n ...SettingsUserEmails_User\n }\n }\n }\n }\n"];
|
||||
export function graphql(source: "\n mutation SettingsDeleteUserEmail($input: DeleteUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n delete(input: $input) {\n id\n emails {\n ...EmailFields\n }\n }\n }\n }\n }\n"): (typeof documents)["\n mutation SettingsDeleteUserEmail($input: DeleteUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n delete(input: $input) {\n id\n emails {\n ...EmailFields\n }\n }\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n mutation SettingsSetPrimaryUserEmail($input: SetPrimaryUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n setPrimary(input: $input) {\n ...SettingsUserEmails_User\n }\n }\n }\n }\n"): (typeof documents)["\n mutation SettingsSetPrimaryUserEmail($input: SetPrimaryUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n setPrimary(input: $input) {\n ...SettingsUserEmails_User\n }\n }\n }\n }\n"];
|
||||
export function graphql(source: "\n mutation SettingsSetPrimaryUserEmail($input: SetPrimaryUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n setPrimary(input: $input) {\n id\n emails {\n ...EmailFields\n }\n }\n }\n }\n }\n"): (typeof documents)["\n mutation SettingsSetPrimaryUserEmail($input: SetPrimaryUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n setPrimary(input: $input) {\n id\n emails {\n ...EmailFields\n }\n }\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -1614,10 +1606,6 @@ export function graphql(source: "\n query SettingsWorkspacesJoinRequestsSearch(
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query SettingsWorkspacesInvitesSearch(\n $slug: String!\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n ) {\n workspaceBySlug(slug: $slug) {\n ...SettingsWorkspacesMembersInvitesTable_Workspace\n }\n }\n"): (typeof documents)["\n query SettingsWorkspacesInvitesSearch(\n $slug: String!\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n ) {\n workspaceBySlug(slug: $slug) {\n ...SettingsWorkspacesMembersInvitesTable_Workspace\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query SettingsUserEmailsQuery {\n activeUser {\n ...SettingsUserEmails_User\n }\n }\n"): (typeof documents)["\n query SettingsUserEmailsQuery {\n activeUser {\n ...SettingsUserEmails_User\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -1650,6 +1638,18 @@ export function graphql(source: "\n mutation UpdateNotificationPreferences($inp
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n mutation DeleteAccount($input: UserDeleteInput!) {\n userDelete(userConfirmation: $input)\n }\n"): (typeof documents)["\n mutation DeleteAccount($input: UserDeleteInput!) {\n userDelete(userConfirmation: $input)\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n mutation verifyEmail($input: VerifyUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n verify(input: $input)\n }\n }\n }\n"): (typeof documents)["\n mutation verifyEmail($input: VerifyUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n verify(input: $input)\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment EmailFields on UserEmail {\n id\n email\n verified\n primary\n userId\n }\n"): (typeof documents)["\n fragment EmailFields on UserEmail {\n id\n email\n verified\n primary\n userId\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query UserEmails {\n activeUser {\n id\n emails {\n ...EmailFields\n }\n hasPendingVerification\n }\n }\n"): (typeof documents)["\n query UserEmails {\n activeUser {\n id\n emails {\n ...EmailFields\n }\n hasPendingVerification\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -1894,6 +1894,10 @@ export function graphql(source: "\n query AutomateFunctionPage($functionId: ID!
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query AutomateFunctionPageWorkspace($workspaceId: String!) {\n workspace(id: $workspaceId) {\n id\n ...AutomateFunctionPageHeader_Workspace\n }\n }\n"): (typeof documents)["\n query AutomateFunctionPageWorkspace($workspaceId: String!) {\n workspace(id: $workspaceId) {\n id\n ...AutomateFunctionPageHeader_Workspace\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment PagesOnboarding_DiscoverableWorkspaces on User {\n discoverableWorkspaces {\n id\n name\n logo\n description\n slug\n }\n }\n"): (typeof documents)["\n fragment PagesOnboarding_DiscoverableWorkspaces on User {\n discoverableWorkspaces {\n id\n name\n logo\n description\n slug\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -1918,10 +1922,6 @@ export function graphql(source: "\n fragment SettingsServerProjects_ProjectColl
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query SettingsServerRegions {\n serverInfo {\n multiRegion {\n regions {\n id\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n availableKeys\n }\n }\n }\n"): (typeof documents)["\n query SettingsServerRegions {\n serverInfo {\n multiRegion {\n regions {\n id\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n availableKeys\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment SettingsUserEmails_User on User {\n id\n emails {\n ...SettingsUserEmailCards_UserEmail\n }\n }\n"): (typeof documents)["\n fragment SettingsUserEmails_User on User {\n id\n emails {\n ...SettingsUserEmailCards_UserEmail\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -13,6 +13,8 @@ export const registerRoute = '/authn/register'
|
||||
export const ssoLoginRoute = '/authn/sso'
|
||||
export const forgottenPasswordRoute = '/authn/forgotten-password'
|
||||
export const onboardingRoute = '/onboarding'
|
||||
export const verifyEmailRoute = '/verify-email'
|
||||
export const verifyEmailCountdownRoute = '/verify-email?source=registration'
|
||||
export const serverManagementRoute = '/server-management'
|
||||
export const downloadManagerUrl = 'https://speckle.systems/download'
|
||||
export const docsPageUrl = 'https://speckle.guide/'
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
|
||||
export const PagesOnboardingDiscoverableWorkspaces = graphql(`
|
||||
query PagesOnboardingDiscoverableWorkspaces_ActiveUser {
|
||||
activeUser {
|
||||
id
|
||||
...PagesOnboarding_DiscoverableWorkspaces
|
||||
}
|
||||
}
|
||||
`)
|
||||
@@ -0,0 +1,4 @@
|
||||
export type OnboardingSelectOption = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
@@ -15,7 +15,10 @@ export const settingsCreateUserEmailMutation = graphql(`
|
||||
activeUserMutations {
|
||||
emailMutations {
|
||||
create(input: $input) {
|
||||
...SettingsUserEmails_User
|
||||
id
|
||||
emails {
|
||||
...EmailFields
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +30,10 @@ export const settingsDeleteUserEmailMutation = graphql(`
|
||||
activeUserMutations {
|
||||
emailMutations {
|
||||
delete(input: $input) {
|
||||
...SettingsUserEmails_User
|
||||
id
|
||||
emails {
|
||||
...EmailFields
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,7 +45,10 @@ export const settingsSetPrimaryUserEmailMutation = graphql(`
|
||||
activeUserMutations {
|
||||
emailMutations {
|
||||
setPrimary(input: $input) {
|
||||
...SettingsUserEmails_User
|
||||
id
|
||||
emails {
|
||||
...EmailFields
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,14 +106,6 @@ export const settingsWorkspacesInvitesSearchQuery = graphql(`
|
||||
}
|
||||
`)
|
||||
|
||||
export const settingsUserEmailsQuery = graphql(`
|
||||
query SettingsUserEmailsQuery {
|
||||
activeUser {
|
||||
...SettingsUserEmails_User
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
export const settingsWorkspacesProjectsQuery = graphql(`
|
||||
query SettingsWorkspacesProjects(
|
||||
$slug: String!
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useApolloClient, useMutation, useQuery } from '@vue/apollo-composable'
|
||||
import {
|
||||
settingsNewEmailVerificationMutation,
|
||||
settingsDeleteUserEmailMutation,
|
||||
settingsCreateUserEmailMutation
|
||||
} from '~/lib/settings/graphql/mutations'
|
||||
import { userEmailsQuery } from '~/lib/user/graphql/queries'
|
||||
import {
|
||||
convertThrowIntoFetchResult,
|
||||
getFirstErrorMessage,
|
||||
getCacheId,
|
||||
modifyObjectField
|
||||
} from '~/lib/common/helpers/graphql'
|
||||
import type { UserEmail } from '~/lib/common/generated/gql/graphql'
|
||||
import { useGlobalToast } from '~/lib/common/composables/toast'
|
||||
import { useMixpanel } from '~/lib/core/composables/mp'
|
||||
import {
|
||||
verifyEmailRoute,
|
||||
homeRoute,
|
||||
settingsUserRoutes
|
||||
} from '~/lib/common/helpers/route'
|
||||
import { verifyEmailMutation } from '~/lib/user/graphql/mutations'
|
||||
|
||||
export function useUserEmails() {
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
const mixpanel = useMixpanel()
|
||||
const { result } = useQuery(userEmailsQuery)
|
||||
const route = useRoute()
|
||||
const apollo = useApolloClient().client
|
||||
const { activeUser } = useActiveUser()
|
||||
|
||||
const { mutate: resendMutation } = useMutation(settingsNewEmailVerificationMutation)
|
||||
const { mutate: deleteMutation } = useMutation(settingsDeleteUserEmailMutation)
|
||||
const { mutate: createMutation } = useMutation(settingsCreateUserEmailMutation)
|
||||
const { mutate: verifyMutation } = useMutation(verifyEmailMutation)
|
||||
|
||||
// Simple array of all emails
|
||||
const emails = computed(() => result.value?.activeUser?.emails ?? ([] as UserEmail[]))
|
||||
|
||||
// Helper computed properties for common queries
|
||||
const unverifiedPrimaryEmail = computed(
|
||||
() => emails.value.find((e) => e.primary && !e.verified) || null
|
||||
)
|
||||
|
||||
const unverifiedEmails = computed(() => emails.value.filter((e) => !e.verified))
|
||||
|
||||
const addUserEmail = async (email: string) => {
|
||||
const result = await createMutation({
|
||||
input: { email }
|
||||
}).catch(convertThrowIntoFetchResult)
|
||||
|
||||
if (result?.data) {
|
||||
mixpanel.track('Email Added')
|
||||
navigateTo(verifyEmailRoute)
|
||||
return true
|
||||
}
|
||||
|
||||
const errorMessage = getFirstErrorMessage(result?.errors)
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Error adding email',
|
||||
description: errorMessage
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const resendVerificationEmail = async (email: UserEmail) => {
|
||||
const result = await resendMutation({
|
||||
input: { id: email.id }
|
||||
}).catch(convertThrowIntoFetchResult)
|
||||
|
||||
if (result?.data) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: `Verification email sent to ${email.email}`
|
||||
})
|
||||
navigateTo(verifyEmailRoute)
|
||||
return true
|
||||
}
|
||||
|
||||
const errorMessage = getFirstErrorMessage(result?.errors)
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Error sending verification email',
|
||||
description: errorMessage
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const deleteUserEmail = async (email: UserEmail, cancel = false) => {
|
||||
const result = await deleteMutation({
|
||||
input: { id: email.id }
|
||||
}).catch(convertThrowIntoFetchResult)
|
||||
|
||||
if (result?.data) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: `${cancel ? 'Cancelled adding email' : 'Deleted email'}`,
|
||||
description: email.email
|
||||
})
|
||||
mixpanel.track('Email Deleted')
|
||||
|
||||
// If we're on the verify email page and there are no more unverified emails, redirect home
|
||||
if (route.path === verifyEmailRoute && unverifiedEmails.value.length === 0) {
|
||||
navigateTo(homeRoute)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const errorMessage = getFirstErrorMessage(result?.errors)
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Error deleting email',
|
||||
description: errorMessage
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const verifyUserEmail = async (email: UserEmail, code: string) => {
|
||||
mixpanel.track('Email Verification Started', {
|
||||
email: email.email,
|
||||
isPrimary: email.primary
|
||||
})
|
||||
|
||||
const result = await verifyMutation({
|
||||
input: { email: email.email, code }
|
||||
}).catch(convertThrowIntoFetchResult)
|
||||
|
||||
const activeUserId = computed(() => activeUser.value?.id)
|
||||
|
||||
if (result?.data?.activeUserMutations?.emailMutations?.verify) {
|
||||
if (!activeUserId.value) return
|
||||
|
||||
mixpanel.track('Email Verified', {
|
||||
email: email.email,
|
||||
isPrimary: email.primary
|
||||
})
|
||||
|
||||
// Update UserEmail verified status in cache
|
||||
modifyObjectField(
|
||||
apollo.cache,
|
||||
getCacheId('UserEmail', email.id),
|
||||
'verified',
|
||||
() => true
|
||||
)
|
||||
|
||||
// Only update User verified status if this is the primary email
|
||||
if (email.primary) {
|
||||
modifyObjectField(
|
||||
apollo.cache,
|
||||
getCacheId('User', activeUserId.value),
|
||||
'verified',
|
||||
() => true
|
||||
)
|
||||
navigateTo(homeRoute)
|
||||
} else {
|
||||
navigateTo(settingsUserRoutes.emails)
|
||||
}
|
||||
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: 'Email verified',
|
||||
description: 'Your email has been successfully verified'
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
mixpanel.track('Email Verification Failed', {
|
||||
email: email.email,
|
||||
isPrimary: email.primary
|
||||
})
|
||||
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Verification failed',
|
||||
description: 'The verification code you entered is incorrect or expired'
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
return {
|
||||
emails,
|
||||
unverifiedPrimaryEmail,
|
||||
unverifiedEmails,
|
||||
addUserEmail,
|
||||
resendVerificationEmail,
|
||||
deleteUserEmail,
|
||||
verifyUserEmail
|
||||
}
|
||||
}
|
||||
@@ -25,3 +25,13 @@ export const deleteAccountMutation = graphql(`
|
||||
userDelete(userConfirmation: $input)
|
||||
}
|
||||
`)
|
||||
|
||||
export const verifyEmailMutation = graphql(`
|
||||
mutation verifyEmail($input: VerifyUserEmailInput!) {
|
||||
activeUserMutations {
|
||||
emailMutations {
|
||||
verify(input: $input)
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
|
||||
export const emailFieldsFragment = graphql(`
|
||||
fragment EmailFields on UserEmail {
|
||||
id
|
||||
email
|
||||
verified
|
||||
primary
|
||||
userId
|
||||
}
|
||||
`)
|
||||
|
||||
export const userEmailsQuery = graphql(`
|
||||
query UserEmails {
|
||||
activeUser {
|
||||
id
|
||||
emails {
|
||||
...EmailFields
|
||||
}
|
||||
hasPendingVerification
|
||||
}
|
||||
}
|
||||
`)
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useConditionalViewerRendering } from '~/lib/viewer/composables/ui'
|
||||
|
||||
export const useTourStageState = () =>
|
||||
useState('viewer-tour-state', () => ({
|
||||
showNavbar: true,
|
||||
showViewerControls: true,
|
||||
showTour: false,
|
||||
showSegmentation: true
|
||||
}))
|
||||
|
||||
export function useViewerTour() {
|
||||
const state = useTourStageState()
|
||||
const conditionalRendering = useConditionalViewerRendering()
|
||||
|
||||
const showNavbar = computed({
|
||||
get: () => conditionalRendering.showNavbar.value,
|
||||
set: (newVal) => (state.value.showNavbar = newVal)
|
||||
})
|
||||
|
||||
const showControls = computed({
|
||||
get: () => conditionalRendering.showControls.value,
|
||||
set: (newVal) => (state.value.showViewerControls = newVal)
|
||||
})
|
||||
|
||||
const showTour = computed({
|
||||
get: () => state.value.showTour,
|
||||
set: (newVal) => (state.value.showTour = newVal)
|
||||
})
|
||||
|
||||
const showSegmentation = computed({
|
||||
get: () => state.value.showSegmentation,
|
||||
set: (newVal) => (state.value.showSegmentation = newVal)
|
||||
})
|
||||
|
||||
return {
|
||||
showNavbar,
|
||||
showControls,
|
||||
showTour,
|
||||
showSegmentation
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
type InjectableViewerState
|
||||
} from '~~/lib/viewer/composables/setup'
|
||||
import { useDiffBuilderUtilities } from '~~/lib/viewer/composables/setup/diff'
|
||||
import { useTourStageState } from '~~/lib/viewer/composables/tour'
|
||||
import { Vector3, Box3 } from 'three'
|
||||
import { getKeyboardShortcutTitle, onKeyboardShortcut } from '@speckle/ui-components'
|
||||
import { ViewerShortcuts } from '~/lib/viewer/helpers/shortcuts/shortcuts'
|
||||
@@ -435,11 +434,9 @@ export function useMeasurementUtilities() {
|
||||
* Some conditional rendering values depend on multiple & overlapping states. This utility reconciles that.
|
||||
*/
|
||||
export function useConditionalViewerRendering() {
|
||||
const tourState = useTourStageState()
|
||||
const embedMode = useEmbedState()
|
||||
|
||||
const showControls = computed(() => {
|
||||
if (tourState.value.showTour && !tourState.value.showViewerControls) return false
|
||||
if (
|
||||
embedMode.embedOptions.value?.isEnabled &&
|
||||
embedMode.embedOptions.value.hideControls
|
||||
@@ -452,7 +449,6 @@ export function useConditionalViewerRendering() {
|
||||
|
||||
const showNavbar = computed(() => {
|
||||
if (!showControls.value) return false
|
||||
if (tourState.value.showTour && !tourState.value.showNavbar) return false
|
||||
if (embedMode.embedOptions.value?.isEnabled) return false
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { homeRoute, verifyEmailRoute } from '~/lib/common/helpers/route'
|
||||
import { activeUserQuery } from '~~/lib/auth/composables/activeUser'
|
||||
import { useApolloClientFromNuxt } from '~~/lib/common/composables/graphql'
|
||||
import { convertThrowIntoFetchResult } from '~~/lib/common/helpers/graphql'
|
||||
|
||||
/**
|
||||
* Redirect user to /verify-email, if they haven't done it yet
|
||||
*/
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const client = useApolloClientFromNuxt()
|
||||
const { data } = await client
|
||||
.query({
|
||||
query: activeUserQuery
|
||||
})
|
||||
.catch(convertThrowIntoFetchResult)
|
||||
|
||||
if (!data?.activeUser?.id) return
|
||||
|
||||
const isAuthPage = to.path.startsWith('/authn/')
|
||||
const isVerifyEmailPage = to.path === verifyEmailRoute
|
||||
|
||||
if (isAuthPage) return
|
||||
|
||||
const hasUnverifiedEmails = data.activeUser.emails.some((email) => !email.verified)
|
||||
|
||||
if (hasUnverifiedEmails) {
|
||||
// Redirect to verify email if not already there
|
||||
if (!isVerifyEmailPage) {
|
||||
return navigateTo(verifyEmailRoute)
|
||||
}
|
||||
} else {
|
||||
if (isVerifyEmailPage) {
|
||||
return navigateTo(homeRoute)
|
||||
}
|
||||
}
|
||||
})
|
||||
+8
@@ -7,6 +7,8 @@ import { homeRoute, onboardingRoute } from '~~/lib/common/helpers/route'
|
||||
* Redirect user to /onboarding, if they haven't done it yet
|
||||
*/
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const isOnboardingForced = useIsOnboardingForced()
|
||||
|
||||
const client = useApolloClientFromNuxt()
|
||||
const { data } = await client
|
||||
.query({
|
||||
@@ -17,6 +19,12 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
// Ignore if not logged in
|
||||
if (!data?.activeUser?.id) return
|
||||
|
||||
// Ignore if force onboarding ff is false
|
||||
if (!isOnboardingForced.value) return
|
||||
|
||||
// Ignore if user has not verified their email yet
|
||||
if (!data?.activeUser?.verified) return
|
||||
|
||||
const isOnboardingFinished = data?.activeUser?.isOnboardingFinished
|
||||
const isGoingToOnboarding = to.path === onboardingRoute
|
||||
const shouldRedirectToOnboarding =
|
||||
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<NuxtPage />
|
||||
<div>
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { loginRoute } from '~~/lib/common/helpers/route'
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
</DisclosureButton>
|
||||
|
||||
<DisclosurePanel
|
||||
class="flex flex-col px-2 py-5 space-y-5 label-light border-b border-outline-3 label-light"
|
||||
class="flex flex-col px-2 py-5 space-y-5 label-light border-b border-outline-3"
|
||||
>
|
||||
<table v-if="app.author || app.description?.length" class="table-fixed">
|
||||
<tbody>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
:disabled="resendVerificationEmailLoading"
|
||||
@click="onResend"
|
||||
>
|
||||
Resend verification
|
||||
Verify email
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,70 +1,106 @@
|
||||
<template>
|
||||
<div class="w-full h-full bg-foundation flex items-center justify-center">
|
||||
<!--
|
||||
Note: You might be asking yourself why do we need this route: the answer is that cloning
|
||||
a project is not instant, and it might take some time to get it done. We want to display
|
||||
some sort of progress to the user in the meantime. Moreover, it makes various composables
|
||||
more sane to use rather than in the router navigation guards.
|
||||
-->
|
||||
<!--
|
||||
TODO: Make this page nicer :)
|
||||
-->
|
||||
<div class="w-1/5 flex flex-col space-y-2 justify-center text-center">
|
||||
<div class="text-xs text-foreground-2">{{ status }}</div>
|
||||
<CommonLoadingBar loading />
|
||||
<!-- <div class="mx-auto w-20"><LogoTextWhite /></div> -->
|
||||
<HeaderWithEmptyPage empty-header>
|
||||
<template #header-left>
|
||||
<HeaderLogoBlock no-link />
|
||||
</template>
|
||||
<template #header-right>
|
||||
<div class="flex gap-2 items-center">
|
||||
<FormButton
|
||||
v-if="!isOnboardingForced"
|
||||
class="opacity-70 hover:opacity-100 p-1"
|
||||
size="sm"
|
||||
color="subtle"
|
||||
@click="setUserOnboardingComplete()"
|
||||
>
|
||||
Skip
|
||||
</FormButton>
|
||||
<FormButton color="outline" @click="() => logout({ skipRedirect: false })">
|
||||
Sign out
|
||||
</FormButton>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col items-center justify-center p-4 max-w-lg mx-auto">
|
||||
<h1 class="text-heading-xl text-forefround mb-2 font-normal">
|
||||
{{ currentStage === 'join' ? 'Join your teammates' : 'Tell us about yourself' }}
|
||||
</h1>
|
||||
<p class="text-center text-body-sm text-foreground-2 mb-8">
|
||||
{{
|
||||
currentStage === 'join'
|
||||
? 'We found a workspace that matches your email domain'
|
||||
: 'Your answers will help us improve'
|
||||
}}
|
||||
</p>
|
||||
|
||||
<template v-if="!loading">
|
||||
<OnboardingJoinTeammates
|
||||
v-if="currentStage === 'join' && discoverableWorkspaces.length > 0"
|
||||
:workspaces="discoverableWorkspaces"
|
||||
@next="currentStage = 'questions'"
|
||||
/>
|
||||
<OnboardingQuestionsForm v-else />
|
||||
</template>
|
||||
<CommonLoadingIcon v-else size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
</HeaderWithEmptyPage>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useViewerTour } from '~/lib/viewer/composables/tour'
|
||||
import {
|
||||
useProcessOnboarding,
|
||||
FIRST_MODEL_NAME
|
||||
} from '~~/lib/auth/composables/onboarding'
|
||||
import { homeRoute, modelRoute, projectRoute } from '~~/lib/common/helpers/route'
|
||||
import { useProcessOnboarding } from '~~/lib/auth/composables/onboarding'
|
||||
import { useAuthManager } from '~/lib/auth/composables/auth'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
|
||||
import { CommonLoadingIcon } from '@speckle/ui-components'
|
||||
import { PagesOnboardingDiscoverableWorkspaces } from '~/lib/onboarding/graphql/queries'
|
||||
|
||||
graphql(`
|
||||
fragment PagesOnboarding_DiscoverableWorkspaces on User {
|
||||
discoverableWorkspaces {
|
||||
id
|
||||
name
|
||||
logo
|
||||
description
|
||||
slug
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
useHead({
|
||||
title: 'Setting up'
|
||||
title: 'Welcome to Speckle'
|
||||
})
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['auth'],
|
||||
layout: 'onboarding'
|
||||
layout: 'empty'
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const { createOnboardingProject, setUserOnboardingComplete } = useProcessOnboarding()
|
||||
const tourStage = useViewerTour()
|
||||
const isOnboardingForced = useIsOnboardingForced()
|
||||
|
||||
const status = ref('Setting up your account')
|
||||
const { setUserOnboardingComplete, createOnboardingProject } = useProcessOnboarding()
|
||||
const { activeUser } = useActiveUser()
|
||||
const { logout } = useAuthManager()
|
||||
|
||||
onMounted(async () => {
|
||||
// Little hacks to make things more exciting
|
||||
setTimeout(() => {
|
||||
status.value = 'Getting there...'
|
||||
}, 2000)
|
||||
const { projectId, project } = await createOnboardingProject()
|
||||
const { result, loading } = useQuery(PagesOnboardingDiscoverableWorkspaces)
|
||||
|
||||
await setUserOnboardingComplete()
|
||||
status.value = 'Almost done!'
|
||||
const currentStage = ref<'join' | 'questions'>('join')
|
||||
|
||||
tourStage.showNavbar.value = false
|
||||
tourStage.showControls.value = false
|
||||
tourStage.showTour.value = true
|
||||
const discoverableWorkspaces = computed(
|
||||
() => result.value?.activeUser?.discoverableWorkspaces || []
|
||||
)
|
||||
|
||||
const firstModelToLoad = project?.models.items.find(
|
||||
(model) => model.name === FIRST_MODEL_NAME
|
||||
)
|
||||
|
||||
if (projectId) {
|
||||
if (firstModelToLoad) {
|
||||
router.push({ path: modelRoute(projectId, firstModelToLoad.id) })
|
||||
} else {
|
||||
router.push({ path: projectRoute(projectId) })
|
||||
watch(
|
||||
loading,
|
||||
(isLoading) => {
|
||||
if (!isLoading && discoverableWorkspaces.value.length === 0) {
|
||||
currentStage.value = 'questions'
|
||||
}
|
||||
} else {
|
||||
router.push({ path: homeRoute })
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (activeUser.value?.versions.totalCount === 0) {
|
||||
createOnboardingProject()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
text="Manage your email addresses"
|
||||
/>
|
||||
<SettingsSectionHeader title="Your emails" subheading />
|
||||
<SettingsUserEmailList class="pt-6" :email-data="emailItems" />
|
||||
<SettingsUserEmailList />
|
||||
<hr class="my-6 md:my-8 border-outline-2" />
|
||||
<SettingsSectionHeader title="Add new email" subheading />
|
||||
<div class="flex flex-col md:flex-row w-full pt-4 md:pt-6 pb-6">
|
||||
@@ -30,28 +30,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { orderBy } from 'lodash-es'
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
|
||||
import { isEmail, isRequired } from '~~/lib/common/helpers/validation'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { useQuery, useMutation } from '@vue/apollo-composable'
|
||||
import { settingsUserEmailsQuery } from '~/lib/settings/graphql/queries'
|
||||
import { settingsCreateUserEmailMutation } from '~/lib/settings/graphql/mutations'
|
||||
import {
|
||||
getFirstErrorMessage,
|
||||
convertThrowIntoFetchResult
|
||||
} from '~~/lib/common/helpers/graphql'
|
||||
import { useMixpanel } from '~/lib/core/composables/mp'
|
||||
|
||||
graphql(`
|
||||
fragment SettingsUserEmails_User on User {
|
||||
id
|
||||
emails {
|
||||
...SettingsUserEmailCards_UserEmail
|
||||
}
|
||||
}
|
||||
`)
|
||||
import { isEmail, isRequired } from '~~/lib/common/helpers/validation'
|
||||
import { useUserEmails } from '~/lib/user/composables/emails'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'settings'
|
||||
@@ -64,41 +45,13 @@ useHead({
|
||||
type FormValues = { email: string }
|
||||
|
||||
const { handleSubmit } = useForm<FormValues>()
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
const { result: userEmailsResult } = useQuery(settingsUserEmailsQuery)
|
||||
const { mutate: createMutation } = useMutation(settingsCreateUserEmailMutation)
|
||||
const mixpanel = useMixpanel()
|
||||
|
||||
const { addUserEmail } = useUserEmails()
|
||||
const email = ref('')
|
||||
|
||||
// Mak sure primary email is always on top, followed by verified emails
|
||||
const emailItems = computed(() =>
|
||||
userEmailsResult.value?.activeUser?.emails
|
||||
? orderBy(
|
||||
userEmailsResult.value?.activeUser.emails,
|
||||
['primary', 'verified'],
|
||||
['desc', 'desc']
|
||||
)
|
||||
: []
|
||||
)
|
||||
|
||||
const onAddEmailSubmit = handleSubmit(async () => {
|
||||
const result = await createMutation({ input: { email: email.value } }).catch(
|
||||
convertThrowIntoFetchResult
|
||||
)
|
||||
if (result?.data) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: `${email.value} added`
|
||||
})
|
||||
|
||||
mixpanel.track('Email Added')
|
||||
} else {
|
||||
const errorMessage = getFirstErrorMessage(result?.errors)
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: errorMessage
|
||||
})
|
||||
const success = await addUserEmail(email.value)
|
||||
if (success) {
|
||||
email.value = ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<HeaderWithEmptyPage empty-header>
|
||||
<template #header-left>
|
||||
<HeaderLogoBlock no-link />
|
||||
</template>
|
||||
<template #header-right>
|
||||
<FormButton
|
||||
v-if="isPrimaryEmail"
|
||||
color="outline"
|
||||
size="sm"
|
||||
@click="() => logout({ skipRedirect: false })"
|
||||
>
|
||||
Sign out
|
||||
</FormButton>
|
||||
<FormButton v-else color="outline" size="sm" @click="showDeleteDialog = true">
|
||||
Cancel
|
||||
</FormButton>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col items-center justify-center p-4">
|
||||
<h1 class="text-heading-xl text-forefround mb-6 font-normal">
|
||||
{{ isPrimaryEmail ? 'Verify your email' : 'Verify additional email' }}
|
||||
</h1>
|
||||
<p class="text-center text-body-sm text-foreground">
|
||||
We sent you a verification code to
|
||||
<span class="font-semibold">{{ currentEmail?.email }}</span>
|
||||
</p>
|
||||
<p class="text-center text-body-sm text-foreground mb-8">
|
||||
Paste (or type) it below to continue. Code expires in 5 minutes.
|
||||
</p>
|
||||
<FormCodeInput
|
||||
v-model="code"
|
||||
:error="hasError"
|
||||
@complete="handleVerificationComplete"
|
||||
/>
|
||||
<div class="mt-8 flex items-center gap-2">
|
||||
<FormButton
|
||||
v-if="!isPrimaryEmail"
|
||||
color="subtle"
|
||||
size="sm"
|
||||
@click="showDeleteDialog = true"
|
||||
>
|
||||
Cancel
|
||||
</FormButton>
|
||||
<div
|
||||
:key="cooldownRemaining"
|
||||
v-tippy="
|
||||
cooldownRemaining > 0
|
||||
? `You can send another code in ${cooldownRemaining}s`
|
||||
: undefined
|
||||
"
|
||||
>
|
||||
<FormButton
|
||||
:disabled="isResendDisabled"
|
||||
color="outline"
|
||||
size="sm"
|
||||
@click="resendEmail"
|
||||
>
|
||||
{{ isResendDisabled ? 'Code sent' : 'Resend code' }}
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!registeredThisSession" class="w-full max-w-sm mx-auto mt-8">
|
||||
<CommonAlert color="neutral" size="xs">
|
||||
<template #title>Why am I seeing this?</template>
|
||||
<template #description>
|
||||
This server now requires you to verify all email addresses before you can
|
||||
access your account.
|
||||
</template>
|
||||
</CommonAlert>
|
||||
</div>
|
||||
|
||||
<SettingsUserEmailDeleteDialog
|
||||
v-model:open="showDeleteDialog"
|
||||
:email="currentEmail"
|
||||
cancel
|
||||
/>
|
||||
</div>
|
||||
</HeaderWithEmptyPage>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FormCodeInput } from '@speckle/ui-components'
|
||||
import { useUserEmails } from '~/lib/user/composables/emails'
|
||||
import { useIntervalFn } from '@vueuse/core'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAuthManager, useRegisteredThisSession } from '~/lib/auth/composables/auth'
|
||||
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
|
||||
import type { UserEmail } from '~/lib/common/generated/gql/graphql'
|
||||
|
||||
useHead({
|
||||
title: 'Verify your email'
|
||||
})
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['auth'],
|
||||
layout: 'empty'
|
||||
})
|
||||
|
||||
const {
|
||||
unverifiedPrimaryEmail,
|
||||
unverifiedEmails,
|
||||
resendVerificationEmail,
|
||||
verifyUserEmail,
|
||||
emails
|
||||
} = useUserEmails()
|
||||
const route = useRoute()
|
||||
const { logout } = useAuthManager()
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
const registeredThisSession = useRegisteredThisSession()
|
||||
|
||||
const code = ref('')
|
||||
const hasError = ref(false)
|
||||
const cooldownRemaining = ref(0)
|
||||
const showDeleteDialog = ref(false)
|
||||
const isLoading = ref(false)
|
||||
|
||||
// Get the email to verify - first check URL param, then fall back to primary or first unverified
|
||||
const currentEmail = computed<UserEmail | undefined>(() => {
|
||||
const emailId = route.query.emailId as string
|
||||
if (emailId) {
|
||||
return emails.value.find((e) => e.id === emailId)
|
||||
}
|
||||
return unverifiedPrimaryEmail.value || (unverifiedEmails.value[0] ?? undefined)
|
||||
})
|
||||
|
||||
const isResendDisabled = computed(() => cooldownRemaining.value > 0)
|
||||
const isPrimaryEmail = computed(() => currentEmail.value?.primary ?? false)
|
||||
|
||||
const { pause: stopInterval, resume: startInterval } = useIntervalFn(
|
||||
() => {
|
||||
if (cooldownRemaining.value > 0) {
|
||||
cooldownRemaining.value--
|
||||
} else {
|
||||
stopInterval()
|
||||
}
|
||||
},
|
||||
1000,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
const resendEmail = async () => {
|
||||
if (!currentEmail.value) return
|
||||
const success = await resendVerificationEmail(currentEmail.value)
|
||||
if (success) {
|
||||
cooldownRemaining.value = 30
|
||||
startInterval()
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerificationComplete = async (code: string) => {
|
||||
if (!currentEmail.value) return
|
||||
if (isLoading.value) return
|
||||
|
||||
hasError.value = false
|
||||
isLoading.value = true
|
||||
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Loading,
|
||||
title: 'Verifying code'
|
||||
})
|
||||
|
||||
try {
|
||||
const success = await verifyUserEmail(currentEmail.value, code)
|
||||
if (!success) {
|
||||
hasError.value = true
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(code, () => {
|
||||
hasError.value = false
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query.source === 'registration') {
|
||||
cooldownRemaining.value = 30
|
||||
startInterval()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -18,7 +18,7 @@
|
||||
</mj-section>
|
||||
<mj-section padding-top="20px">
|
||||
<mj-column>
|
||||
<mj-text font-size="12px" line-height="18px" align="center" color="#e0e0e0">
|
||||
<mj-text font-size="12px" line-height="18px" align="center" color="#999999">
|
||||
Brought to you by
|
||||
<a href="https://speckle.systems" target="_blank">Speckle</a>
|
||||
, the Open Source Data Platform for 3D Data. Follow Us
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
<mj-spacer height="10px" />
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section border-radius="8px" mj-class="bgblue" padding="10px 0 15px 0">
|
||||
<mj-column>
|
||||
<mj-image
|
||||
width="100px"
|
||||
src="<%- params.serverInfo.canonicalUrl -%>/static/logo-slab-white.png"
|
||||
width="150px"
|
||||
src="<%- params.serverInfo.canonicalUrl -%>/static/speckle-email-logo.png"
|
||||
alt="Speckle"
|
||||
/>
|
||||
</mj-column>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
@@ -101,33 +101,17 @@ const createNewEmailVerificationFactory =
|
||||
}
|
||||
|
||||
function buildMjmlBody(verificationCode: string) {
|
||||
const bodyStart = `<mj-text>Hello,<br/><br/>You have just registered to the Speckle server, or initiated the email verification process manually. To finalize the verification process, use the code below.</mj-text>`
|
||||
const bodyEnd = `<mj-text>This code expires in <strong>5 minutes</strong>: <br/>
|
||||
<strong>${verificationCode}</strong>
|
||||
<br />
|
||||
If the code does not work, please proceed by</mj-text><br/>
|
||||
<mj-list>
|
||||
<mj-li>Logging in with your e-mail address and password</mj-li>
|
||||
<mj-li>Clicking on the Notification icon</mj-li>
|
||||
<mj-li>Selecting "Send Verification"</mj-li>
|
||||
<mj-li>Verifying your e-mail address by using the new code</mj-li>
|
||||
</mj-list><br/>
|
||||
<mj-text>
|
||||
See you soon,<br/>
|
||||
Speckle
|
||||
</mj-text>
|
||||
`
|
||||
|
||||
return { bodyStart, bodyEnd }
|
||||
const bodyStart = `<mj-text><p style="text-align: center; line-height: 2; margin-top:0;">You have just registered to the Speckle server, or initiated the email verification process manually. To finalize the verification process, use the code below.</p></mj-text>
|
||||
<mj-text><strong style="font-size: 32px;text-align: center; display:block;margin-bottom: 5px;">${verificationCode}</strong></mj-text>
|
||||
<mj-text><p style="text-align: center">This code will expire in 5 minutes. Please do not disclose this code to others.</p>
|
||||
<p style="text-align: center">If you did not make this request, please disregard this email.</p></mj-text>
|
||||
<mj-text><p style="text-align: center;">See you soon,<br />Speckle</p></mj-text>`
|
||||
return { bodyStart }
|
||||
}
|
||||
|
||||
function buildTextBody(verificationCode: string) {
|
||||
const bodyStart = `Hello,\n\nYou have just registered to the Speckle server, or initiated the email verification process manually. To finalize the verification process, use the code below:`
|
||||
const bodyEnd = `This code expires in 5 minutes:
|
||||
${verificationCode}
|
||||
\r\n
|
||||
If the code does not work, please proceed by logging in to your Speckle account with your e-mail address and password, clicking the Notification icon, selecting "Send Verification" and verifying your e-mail address by new code.\n\nSee you soon,\nSpeckle
|
||||
`
|
||||
function buildTextBody() {
|
||||
const bodyStart = ``
|
||||
const bodyEnd = ``
|
||||
|
||||
return { bodyStart, bodyEnd }
|
||||
}
|
||||
@@ -135,7 +119,7 @@ If the code does not work, please proceed by logging in to your Speckle account
|
||||
function buildEmailTemplateParams(verificationCode: string): EmailTemplateParams {
|
||||
return {
|
||||
mjml: buildMjmlBody(verificationCode),
|
||||
text: buildTextBody(verificationCode)
|
||||
text: buildTextBody()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||
import FormCodeInput from '~~/src/components/form/CodeInput.vue'
|
||||
|
||||
type StoryType = StoryObj<
|
||||
Record<string, unknown> & {
|
||||
'update:modelValue': (val: string) => void
|
||||
complete: (val: string) => void
|
||||
}
|
||||
>
|
||||
|
||||
export default {
|
||||
component: FormCodeInput,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'A verification code input component that handles digit-by-digit entry with auto-advance and paste support.'
|
||||
}
|
||||
}
|
||||
},
|
||||
argTypes: {
|
||||
'update:modelValue': {
|
||||
type: 'function',
|
||||
action: 'v-model'
|
||||
},
|
||||
complete: {
|
||||
type: 'function',
|
||||
action: 'complete'
|
||||
},
|
||||
digitCount: {
|
||||
control: { type: 'number' }
|
||||
}
|
||||
}
|
||||
} as Meta
|
||||
|
||||
export const Default: StoryType = {
|
||||
render: (args, ctx) => ({
|
||||
components: { FormCodeInput },
|
||||
setup: () => ({ args }),
|
||||
template: `
|
||||
<div class="flex justify-center p-4">
|
||||
<FormCodeInput
|
||||
v-bind="args"
|
||||
@update:modelValue="onModelUpdate"
|
||||
@complete="args.complete"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
methods: {
|
||||
onModelUpdate(val: string) {
|
||||
args['update:modelValue'](val)
|
||||
ctx.updateArgs({ ...args, modelValue: val })
|
||||
}
|
||||
}
|
||||
}),
|
||||
args: {
|
||||
modelValue: '',
|
||||
digitCount: 6,
|
||||
disabled: false,
|
||||
errorMessage: '',
|
||||
error: false,
|
||||
complete: (val: string) => console.log('Complete:', val)
|
||||
}
|
||||
}
|
||||
|
||||
export const Disabled: StoryType = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
disabled: true,
|
||||
modelValue: '123456'
|
||||
}
|
||||
}
|
||||
|
||||
export const DifferentLength: StoryType = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
digitCount: 4
|
||||
}
|
||||
}
|
||||
|
||||
export const WithError: StoryType = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
error: true,
|
||||
modelValue: '123456'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="flex gap-2">
|
||||
<div v-for="(_, index) in digitCount" :key="index" class="w-10">
|
||||
<FormTextInput
|
||||
ref="inputRefs"
|
||||
v-model="digits[index]"
|
||||
class="text-center !text-[14px] py-6 !px-2 font-semibold"
|
||||
color="foundation"
|
||||
:name="`code-${index}`"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
:disabled="disabled"
|
||||
:error="internalError"
|
||||
:custom-error-message="internalError ? ' ' : undefined"
|
||||
maxlength="1"
|
||||
size="lg"
|
||||
@input="onInput(index)"
|
||||
@keydown="onKeyDown(index, $event)"
|
||||
@paste="onPaste"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { FormTextInput } from '~~/src/lib'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
digitCount?: number
|
||||
disabled?: boolean
|
||||
error?: boolean
|
||||
clearErrorOnEdit?: boolean
|
||||
}>(),
|
||||
{
|
||||
digitCount: 6,
|
||||
clearErrorOnEdit: true
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'complete'])
|
||||
|
||||
const inputRefs = ref<Array<HTMLInputElement | null>>([])
|
||||
const digits = ref<string[]>(new Array(props.digitCount).fill('') as string[])
|
||||
const internalError = ref(props.error)
|
||||
|
||||
const onInput = (index: number) => {
|
||||
if (props.clearErrorOnEdit) {
|
||||
internalError.value = false
|
||||
}
|
||||
|
||||
digits.value[index] = digits.value[index].replace(/[^0-9]/g, '')
|
||||
|
||||
// Move to next input if available
|
||||
if (digits.value[index] && index < props.digitCount - 1) {
|
||||
inputRefs.value[index + 1]?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyDown = (index: number, event: KeyboardEvent) => {
|
||||
if (event.key === 'Backspace' && !digits.value[index] && index > 0) {
|
||||
if (props.clearErrorOnEdit) {
|
||||
internalError.value = false
|
||||
}
|
||||
// Move to previous input on backspace if current is empty
|
||||
digits.value[index - 1] = ''
|
||||
inputRefs.value[index - 1]?.focus()
|
||||
} else if (event.key === 'ArrowLeft' && index > 0) {
|
||||
// Move to previous input on left arrow
|
||||
inputRefs.value[index - 1]?.focus()
|
||||
} else if (event.key === 'ArrowRight' && index < props.digitCount - 1) {
|
||||
// Move to next input on right arrow
|
||||
inputRefs.value[index + 1]?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const onPaste = (event: ClipboardEvent) => {
|
||||
if (props.clearErrorOnEdit) {
|
||||
internalError.value = false
|
||||
}
|
||||
event.preventDefault()
|
||||
const pastedData = event.clipboardData?.getData('text')
|
||||
if (!pastedData) return
|
||||
|
||||
const numbers = pastedData.replace(/[^0-9]/g, '').split('')
|
||||
|
||||
digits.value = [
|
||||
...numbers.slice(0, props.digitCount),
|
||||
...(Array(Math.max(0, props.digitCount - numbers.length)).fill('') as string[])
|
||||
]
|
||||
|
||||
// Focus the next empty input or the last input
|
||||
const nextEmptyIndex = digits.value.findIndex((d) => !d)
|
||||
if (nextEmptyIndex !== -1) {
|
||||
inputRefs.value[nextEmptyIndex]?.focus()
|
||||
} else {
|
||||
inputRefs.value[props.digitCount - 1]?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
// Focus first input on mount
|
||||
onMounted(() => {
|
||||
if (inputRefs.value[0]) {
|
||||
inputRefs.value[0].focus()
|
||||
}
|
||||
})
|
||||
|
||||
// Watch external error prop changes
|
||||
watch(
|
||||
() => props.error,
|
||||
(newValue) => {
|
||||
internalError.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
// Watch for external value changes
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
if (newValue) {
|
||||
const newDigits = newValue.split('')
|
||||
digits.value = [
|
||||
...newDigits,
|
||||
...(Array(props.digitCount - newDigits.length).fill('') as string[])
|
||||
]
|
||||
} else {
|
||||
digits.value = Array(props.digitCount).fill('') as string[]
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Watch for completion
|
||||
watch(
|
||||
digits,
|
||||
(newDigits) => {
|
||||
const value = newDigits.join('')
|
||||
emit('update:modelValue', value)
|
||||
|
||||
// Emit complete when all digits are filled
|
||||
if (value.length === props.digitCount) {
|
||||
emit('complete', value)
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
</script>
|
||||
@@ -358,12 +358,6 @@ const leadingIconClasses = computed(() => {
|
||||
const iconClasses = computed((): string => {
|
||||
const classParts: string[] = []
|
||||
|
||||
if (props.customIcon) {
|
||||
classParts.push('pl-8')
|
||||
} else {
|
||||
classParts.push('pl-2')
|
||||
}
|
||||
|
||||
if (!slots['input-right']) {
|
||||
if (props.rightIcon || errorMessage.value || shouldShowClear.value) {
|
||||
classParts.push('pr-8')
|
||||
|
||||
@@ -132,7 +132,9 @@
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="overflow-auto simple-scrollbar max-h-60 flex flex-col">
|
||||
<div
|
||||
class="overflow-auto simple-scrollbar max-h-60 gap-1 flex flex-col"
|
||||
>
|
||||
<div v-if="isAsyncSearchMode && isAsyncLoading" class="px-1">
|
||||
<CommonLoadingBar :loading="true" />
|
||||
</div>
|
||||
@@ -162,7 +164,6 @@
|
||||
<div
|
||||
class="block w-full px-2 py-1.5 rounded-md text-left flex items-center gap-1"
|
||||
:class="[
|
||||
isSelected(item) ? 'bg-highlight-3' : '',
|
||||
!hideCheckmarks ? 'pr-8' : 'pr-2',
|
||||
!disabledItemPredicate?.(item) && !isSelected(item)
|
||||
? 'hover:bg-highlight-1'
|
||||
|
||||
@@ -40,6 +40,10 @@
|
||||
class="text-foreground-2 h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<CommonLoadingIcon
|
||||
v-else-if="notification.type === ToastNotificationType.Loading"
|
||||
class="h-4 w-4 opacity-80"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full min-w-[10rem]">
|
||||
<p
|
||||
@@ -94,6 +98,7 @@ import { computed } from 'vue'
|
||||
import type { MaybeNullOrUndefined } from '@speckle/shared'
|
||||
import { ToastNotificationType } from '~~/src/helpers/global/toast'
|
||||
import type { ToastNotification } from '~~/src/helpers/global/toast'
|
||||
import { CommonLoadingIcon } from '~~/src/lib'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:notification', val: MaybeNullOrUndefined<ToastNotification>): void
|
||||
|
||||
@@ -91,7 +91,7 @@ export function useTextInputCore<V extends string | string[] = string>(params: {
|
||||
const color = unref(props.color)
|
||||
if (color === 'foundation') {
|
||||
classParts.push(
|
||||
'bg-foundation !border border-outline-2 hover:border-outline-5 focus-visible:border-outline-4 !ring-0 focus-visible:!outline-0 !text-[13px]'
|
||||
'bg-foundation !border border-outline-2 hover:border-outline-5 focus-visible:border-outline-4 !ring-0 focus-visible:!outline-0'
|
||||
)
|
||||
} else if (color === 'transparent') {
|
||||
classParts.push('bg-transparent')
|
||||
|
||||
@@ -2,7 +2,8 @@ export enum ToastNotificationType {
|
||||
Success,
|
||||
Warning,
|
||||
Danger,
|
||||
Info
|
||||
Info,
|
||||
Loading
|
||||
}
|
||||
|
||||
export type ToastNotification = {
|
||||
|
||||
@@ -35,6 +35,7 @@ import FormSelectBadges from '~~/src/components/form/select/Badges.vue'
|
||||
import FormSelectMulti from '~~/src/components/form/select/Multi.vue'
|
||||
import FormSwitch from '~~/src/components/form/Switch.vue'
|
||||
import FormClipboardInput from '~~/src/components/form/ClipboardInput.vue'
|
||||
import FormCodeInput from '~~/src/components/form/CodeInput.vue'
|
||||
import CommonLoadingBar from '~~/src/components/common/loading/Bar.vue'
|
||||
import SourceAppBadge from '~~/src/components/SourceAppBadge.vue'
|
||||
import { onKeyboardShortcut, useFormCheckboxModel } from '~~/src/composables/form/input'
|
||||
@@ -130,6 +131,7 @@ export {
|
||||
FormTextInput,
|
||||
FormSwitch,
|
||||
FormClipboardInput,
|
||||
FormCodeInput,
|
||||
ValidationHelpers,
|
||||
useWrappingContainerHiddenCount,
|
||||
useFormSelectChildInternals,
|
||||
|
||||
Reference in New Issue
Block a user