Skip to content

Commit abac4fd

Browse files
amir20claude
andauthored
feat: welcome modal after cloud linking (#4624)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ac43dca commit abac4fd

File tree

27 files changed

+674
-17
lines changed

27 files changed

+674
-17
lines changed

assets/components.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,12 +104,14 @@ declare module 'vue' {
104104
'Mdi:chartBar': typeof import('~icons/mdi/chart-bar')['default']
105105
'Mdi:chartLine': typeof import('~icons/mdi/chart-line')['default']
106106
'Mdi:check': typeof import('~icons/mdi/check')['default']
107+
'Mdi:checkCircle': typeof import('~icons/mdi/check-circle')['default']
107108
'Mdi:chevronDoubleDown': typeof import('~icons/mdi/chevron-double-down')['default']
108109
'Mdi:chevronDown': typeof import('~icons/mdi/chevron-down')['default']
109110
'Mdi:chevronLeft': typeof import('~icons/mdi/chevron-left')['default']
110111
'Mdi:chevronRight': typeof import('~icons/mdi/chevron-right')['default']
111112
'Mdi:close': typeof import('~icons/mdi/close')['default']
112113
'Mdi:cloud': typeof import('~icons/mdi/cloud')['default']
114+
'Mdi:cloudOffOutline': typeof import('~icons/mdi/cloud-off-outline')['default']
113115
'Mdi:cloudOutline': typeof import('~icons/mdi/cloud-outline')['default']
114116
'Mdi:cog': typeof import('~icons/mdi/cog')['default']
115117
'Mdi:contentCopy': typeof import('~icons/mdi/content-copy')['default']
@@ -127,6 +129,7 @@ declare module 'vue' {
127129
'Mdi:pencilOutline': typeof import('~icons/mdi/pencil-outline')['default']
128130
'Mdi:plus': typeof import('~icons/mdi/plus')['default']
129131
'Mdi:poll': typeof import('~icons/mdi/poll')['default']
132+
'Mdi:refresh': typeof import('~icons/mdi/refresh')['default']
130133
'Mdi:satelliteVariant': typeof import('~icons/mdi/satellite-variant')['default']
131134
'Mdi:textBoxOutline': typeof import('~icons/mdi/text-box-outline')['default']
132135
'Mdi:trashCanOutline': typeof import('~icons/mdi/trash-can-outline')['default']
@@ -184,6 +187,7 @@ declare module 'vue' {
184187
Toggle: typeof import('./components/common/Toggle.vue')['default']
185188
ViewerWithSource: typeof import('./components/LogViewer/ViewerWithSource.vue')['default']
186189
WebhookDestinationForm: typeof import('./components/Notification/WebhookDestinationForm.vue')['default']
190+
WelcomeModal: typeof import('./components/WelcomeModal.vue')['default']
187191
ZigZag: typeof import('./components/LogViewer/ZigZag.vue')['default']
188192
}
189193
}

assets/components/CloudPopover.vue

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@
116116
</div>
117117
</template>
118118
</Dropdown>
119+
<WelcomeModal ref="welcomeModal" />
119120
</template>
120121

121122
<script lang="ts" setup>
@@ -125,8 +126,9 @@ const cloudLinkUrl = `${cloudUrl}/link?appUrl=${encodeURIComponent(callbackUrl)}
125126
126127
const { cloudConfig, cloudStatus, cloudStatusError, isLoadingCloudStatus, fetchCloudConfig, fetchCloudStatus } =
127128
useCloudConfig();
128-
const { showToast } = useToast();
129-
const { t } = useI18n();
129+
130+
const welcomeModal = ref<{ open: () => void }>();
131+
const cloudWelcomeShown = useProfileStorage("cloudWelcomeShown", false);
130132
131133
const usagePercent = computed(() => {
132134
if (!cloudStatus.value) return 0;
@@ -146,17 +148,11 @@ onMounted(async () => {
146148
fetchCloudStatus();
147149
}
148150
149-
// Handle successful OAuth return
150-
if (window.location.hash === "#cloudLinked") {
151-
showToast(
152-
{
153-
type: "info",
154-
title: t("notifications.cloud-link-success.title"),
155-
message: t("notifications.cloud-link-success.message"),
156-
},
157-
{ expire: 6000 },
158-
);
159-
history.replaceState(null, "", window.location.pathname + window.location.search);
151+
// Handle successful OAuth return — show welcome modal
152+
if (window.location.hash === "#cloudLinked" && !cloudWelcomeShown.value) {
153+
cloudWelcomeShown.value = true;
154+
nextTick(() => welcomeModal.value?.open());
155+
history.replaceState(history.state, "", window.location.pathname + window.location.search);
160156
}
161157
});
162158
</script>

assets/components/CloudSettingsCard.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
<mdi:refresh class="text-base" />
4040
{{ $t("button.retry") }}
4141
</button>
42-
<button class="btn btn-outline btn-sm btn-error" @click="confirmUnlink">
42+
<button class="btn btn-sm btn-error" @click="confirmUnlink">
4343
<mdi:link-variant-off class="text-base" />
4444
{{ $t("cloud.unlink") }}
4545
</button>

assets/components/Notification/AlertForm.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ const props = defineProps<{
186186
logExpression?: string;
187187
metricExpression?: string;
188188
eventExpression?: string;
189+
dispatcherId?: number;
189190
};
190191
}>();
191192

assets/components/WelcomeModal.vue

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
<template>
2+
<dialog ref="modal" class="modal" @close="onClose">
3+
<div class="modal-box max-w-md p-8">
4+
<!-- Step 1: Feedback -->
5+
<template v-if="step === 'step1'">
6+
<div class="flex flex-col items-center gap-2 text-center">
7+
<mdi:check-circle class="text-success text-4xl" />
8+
<h3 class="text-xl font-bold">{{ $t("cloud.welcome.title") }}</h3>
9+
<p class="text-base-content/60 text-sm">{{ $t("cloud.welcome.subtitle") }}</p>
10+
</div>
11+
12+
<div class="divider"></div>
13+
14+
<p class="mb-3 text-sm font-medium">{{ $t("cloud.welcome.question") }}</p>
15+
16+
<textarea
17+
v-model="intent"
18+
class="textarea textarea-bordered w-full text-sm"
19+
rows="3"
20+
:placeholder="$t('cloud.welcome.placeholder')"
21+
></textarea>
22+
23+
<p class="text-base-content/60 mt-3 mb-2 text-xs">{{ $t("cloud.welcome.or-pick") }}</p>
24+
<div class="flex flex-wrap gap-2">
25+
<button
26+
v-for="option in chipOptions"
27+
:key="option.value"
28+
class="btn btn-sm"
29+
:class="selectedOptions.has(option.value) ? 'btn-primary' : 'btn-outline'"
30+
@click="toggleOption(option.value)"
31+
>
32+
{{ option.label }}
33+
</button>
34+
</div>
35+
36+
<button class="btn btn-primary btn-block mt-6" :disabled="submitting" @click="submitFeedback">
37+
<span v-if="submitting" class="loading loading-spinner loading-xs"></span>
38+
{{ $t("cloud.welcome.get-started") }}
39+
</button>
40+
<button class="btn btn-ghost btn-block btn-sm mt-1" :disabled="submitting" @click="skipFeedback">
41+
{{ $t("cloud.welcome.skip") }}
42+
</button>
43+
</template>
44+
45+
<!-- Step 2: Onboarding Checklist -->
46+
<template v-else-if="step === 'step2'">
47+
<h3 class="text-xl font-bold">{{ $t("cloud.welcome.step2-title") }}</h3>
48+
<p class="text-base-content/60 mt-1 text-sm">{{ $t("cloud.welcome.step2-subtitle") }}</p>
49+
50+
<div class="mt-6 space-y-4">
51+
<div v-for="(item, index) in checklistItems" :key="index" class="flex gap-3">
52+
<div
53+
class="flex size-7 shrink-0 items-center justify-center rounded-full text-xs font-bold"
54+
:class="index === 0 ? 'bg-primary text-primary-content' : 'bg-base-200 text-base-content/60'"
55+
>
56+
{{ index + 1 }}
57+
</div>
58+
<div>
59+
<component
60+
:is="item.href ? 'a' : 'p'"
61+
class="text-sm font-semibold"
62+
:class="item.href ? 'link link-hover' : ''"
63+
:href="item.href"
64+
:target="item.href ? '_blank' : undefined"
65+
:rel="item.href ? 'noreferrer noopener' : undefined"
66+
>
67+
{{ item.title }}
68+
</component>
69+
<p class="text-base-content/60 text-xs">{{ item.description }}</p>
70+
</div>
71+
</div>
72+
</div>
73+
74+
<button class="btn btn-primary btn-block mt-6" @click="createFirstAlert">
75+
{{ $t("cloud.welcome.create-alert") }}
76+
</button>
77+
<button class="btn btn-ghost btn-block btn-sm mt-1" @click="close">
78+
{{ $t("cloud.welcome.later") }}
79+
</button>
80+
</template>
81+
</div>
82+
<form method="dialog" class="modal-backdrop">
83+
<button></button>
84+
</form>
85+
</dialog>
86+
</template>
87+
88+
<script lang="ts" setup>
89+
const cloudUrl = __CLOUD_URL__;
90+
const { t } = useI18n();
91+
const router = useRouter();
92+
const route = useRoute();
93+
94+
const modal = ref<HTMLDialogElement>();
95+
const step = ref<"step1" | "step2">("step1");
96+
const intent = ref("");
97+
const selectedOptions = ref(new Set<string>());
98+
const submitting = ref(false);
99+
let feedbackSent = false;
100+
101+
const chipOptions = [
102+
{ value: "alerts", label: t("cloud.welcome.chip-alerts") },
103+
{ value: "daily_summary", label: t("cloud.welcome.chip-summary") },
104+
{ value: "multiple_hosts", label: t("cloud.welcome.chip-hosts") },
105+
{ value: "something_else", label: t("cloud.welcome.chip-other") },
106+
];
107+
108+
const checklistItems = computed(() => [
109+
{
110+
title: t("cloud.welcome.checklist-alert-title"),
111+
description: t("cloud.welcome.checklist-alert-desc"),
112+
},
113+
{
114+
title: t("cloud.welcome.checklist-notify-title"),
115+
description: t("cloud.welcome.checklist-notify-desc"),
116+
href: `${cloudUrl}/channels`,
117+
},
118+
{
119+
title: t("cloud.welcome.checklist-agent-title"),
120+
description: t("cloud.welcome.checklist-agent-desc"),
121+
href: `${cloudUrl}/assistant`,
122+
},
123+
]);
124+
125+
function toggleOption(value: string) {
126+
const next = new Set(selectedOptions.value);
127+
if (next.has(value)) {
128+
next.delete(value);
129+
} else {
130+
next.add(value);
131+
}
132+
selectedOptions.value = next;
133+
}
134+
135+
async function postFeedback(skipped: boolean) {
136+
try {
137+
await fetch(withBase("/api/cloud/feedback"), {
138+
method: "POST",
139+
headers: { "Content-Type": "application/json" },
140+
body: JSON.stringify({
141+
source: "welcome_modal",
142+
intent: skipped ? undefined : intent.value || undefined,
143+
selectedOptions: skipped ? undefined : Array.from(selectedOptions.value),
144+
skipped,
145+
}),
146+
});
147+
} catch {
148+
// Feedback failure should not block the user
149+
}
150+
}
151+
152+
const onNotificationsPage = computed(() => route.path === "/notifications");
153+
154+
async function submitFeedback() {
155+
submitting.value = true;
156+
feedbackSent = true;
157+
await postFeedback(false);
158+
submitting.value = false;
159+
if (onNotificationsPage.value) {
160+
createFirstAlert();
161+
} else {
162+
step.value = "step2";
163+
}
164+
}
165+
166+
async function skipFeedback() {
167+
submitting.value = true;
168+
feedbackSent = true;
169+
await postFeedback(true);
170+
submitting.value = false;
171+
if (onNotificationsPage.value) {
172+
createFirstAlert();
173+
} else {
174+
step.value = "step2";
175+
}
176+
}
177+
178+
function createFirstAlert() {
179+
close();
180+
router.push({ path: "/notifications", query: { action: "create-alert" } });
181+
}
182+
183+
function open() {
184+
step.value = "step1";
185+
intent.value = "";
186+
selectedOptions.value = new Set();
187+
feedbackSent = false;
188+
modal.value?.showModal();
189+
}
190+
191+
function close() {
192+
modal.value?.close();
193+
}
194+
195+
function onClose() {
196+
if (step.value === "step1" && !feedbackSent) {
197+
feedbackSent = true;
198+
postFeedback(true);
199+
}
200+
}
201+
202+
defineExpose({ open });
203+
</script>

assets/composable/alertForm.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export interface AlertFormOptions {
1313
logExpression?: string;
1414
metricExpression?: string;
1515
eventExpression?: string;
16+
dispatcherId?: number;
1617
};
1718
}
1819

@@ -25,7 +26,7 @@ export function useAlertForm(options: AlertFormOptions) {
2526
const isEditing = computed(() => !!options.alert);
2627
const alertName = ref(options.alert?.name ?? options.prefill?.name ?? "");
2728
const containerExpression = ref(options.alert?.containerExpression ?? options.prefill?.containerExpression ?? "");
28-
const dispatcherId = ref(options.alert?.dispatcher?.id ?? -1);
29+
const dispatcherId = ref(options.alert?.dispatcher?.id ?? options.prefill?.dispatcherId ?? -1);
2930
const isSaving = ref(false);
3031
const saveError = ref<string | null>(null);
3132

assets/pages/notifications.vue

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,10 @@ import type { NotificationRule, Dispatcher } from "@/types/notifications";
9292
import AlertForm from "@/components/Notification/AlertForm.vue";
9393
import DestinationForm from "@/components/Notification/DestinationForm.vue";
9494
95+
const { t } = useI18n();
9596
const showDrawer = useDrawer();
9697
const router = useRouter();
98+
const route = useRoute();
9799
98100
// State
99101
const alerts = ref<NotificationRule[]>([]);
@@ -119,8 +121,23 @@ onMounted(async () => {
119121
if (hash === "#cloudLinked") {
120122
router.replace({ hash: "" });
121123
}
124+
125+
if (route.query.action === "create-alert") {
126+
router.replace({ query: {} });
127+
openCreateAlertPrefilled();
128+
}
122129
});
123130
131+
watch(
132+
() => route.query.action,
133+
(action) => {
134+
if (action === "create-alert") {
135+
router.replace({ query: {} });
136+
openCreateAlertPrefilled();
137+
}
138+
},
139+
);
140+
124141
// Local state
125142
const filter = ref<"all" | "enabled" | "paused">("all");
126143
@@ -137,6 +154,22 @@ function openCreateAlert() {
137154
showDrawer(AlertForm, { onCreated: fetchAlerts }, "lg");
138155
}
139156
157+
function openCreateAlertPrefilled() {
158+
const cloudDispatcher = dispatchers.value.find((d) => d.type === "cloud");
159+
showDrawer(
160+
AlertForm,
161+
{
162+
onCreated: fetchAlerts,
163+
prefill: {
164+
name: t("notifications.prefill-name"),
165+
logExpression: t("notifications.prefill-expression"),
166+
...(cloudDispatcher ? { dispatcherId: cloudDispatcher.id } : {}),
167+
},
168+
},
169+
"lg",
170+
);
171+
}
172+
140173
function openAddDestination() {
141174
showDrawer(
142175
DestinationForm,

assets/stores/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export interface Profile {
3131
visibleKeys?: Map<string, Map<string[], boolean>>;
3232
releaseSeen?: string;
3333
collapsedGroups?: Set<string>;
34+
cloudWelcomeShown?: boolean;
3435
}
3536

3637
const pageConfig = JSON.parse(text);

0 commit comments

Comments
 (0)