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:
andrewwallacespeckle
2025-02-14 10:20:14 +00:00
committed by GitHub
parent 4f35d994b4
commit 91cb011ded
70 changed files with 1419 additions and 1744 deletions
@@ -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>
+3 -36
View File
@@ -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)
}
}
})
@@ -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 =
+3 -1
View File
@@ -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>
+85 -49
View File
@@ -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>
+183
View File
@@ -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 = {
+2
View File
@@ -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,