1<template>
2 <div class="relative mx-auto w-full max-w-md flex flex-col gap-5">
3 <ODLogo class="size-8" />
4
5 <!-- Fixed height keeps the panel from resizing as questions vary in length. -->
6 <div class="relative">
7 <Button
8 v-if="current > 0"
9 variant="ghost"
10 icon="lucide-chevron-left"
11 class="!size-7 shrink-0 !rounded-5 absolute -left-11 mt-4"
12 @click="back"
13 />
14 <div
15 :key="question.key"
16 class="mx-0 min-w-0 border-0 p-0 flex gap-5 flex-col"
17 >
18 <div>
19 <p class="p-0 text-p-xl font-bold text-ink-gray-9">
20 {{ question.title
21 }}<span v-if="titleRequired" class="ms-0.5 text-ink-red-6">*</span>
22 </p>
23
24 <p class="text-base pt-1.5">
25 {{ __("This helps us personalize your Opendesk experience.") }}
26 </p>
27 </div>
28 <template v-if="question.type === 'text'">
29 <div class="flex flex-col gap-3">
30 <!-- Cap textarea resize: vertical only, between one row block and ~2x default. -->
31 <div
32 class="[&_textarea]:resize-y [&_textarea]:min-h-16 [&_textarea]:max-h-40"
33 >
34 <span
35 v-if="question.label"
36 class="mb-1.5 block text-p-sm text-ink-gray-5"
37 >
38 {{ question.label
39 }}<span v-if="question.required" class="ms-0.5 text-ink-red-6"
40 >*</span
41 >
42 </span>
43 <Textarea
44 v-if="question.multiline"
45 v-model="answers[question.key]"
46 size="sm"
47 variant="outline"
48 :rows="3"
49 :placeholder="question.placeholder"
50 />
51 <TextInput
52 v-else
53 v-model="answers[question.key]"
54 size="sm"
55 type="text"
56 variant="outline"
57 :placeholder="question.placeholder"
58 @keyup.enter="proceed"
59 />
60 </div>
61 <div v-if="dropdown">
62 <span class="mb-1.5 block text-p-sm text-ink-gray-5">
63 {{ dropdown.label }}
64 </span>
65 <Select
66 v-model="answers[dropdown.key]"
67 class="w-full"
68 size="sm"
69 variant="outline"
70 :placeholder="dropdown.placeholder"
71 :options="dropdown.options"
72 />
73 </div>
74 </div>
75 </template>
76 <template v-else>
77 <div class="flex flex-wrap gap-3 !text-ink-gray-7 text-p-base">
78 <Button
79 v-for="option in choiceOptions"
80 :key="option.value"
81 :label="option.label"
82 :variant="isSelected(option) ? 'subtle' : 'outline'"
83 size="sm"
84 class="!rounded-5 border border-solid"
85 :class="
86 isSelected(option)
87 ? '!bg-surface-gray-3 hover:!bg-surface-gray-4 !border-outline-gray-3'
88 : 'hover:!bg-surface-alpha-gray-2'
89 "
90 @click="select(option)"
91 />
92 </div>
93 <div
94 class="-mt-5 grid transition-[grid-template-rows,opacity] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
95 :class="
96 isOtherSelected
97 ? 'grid-rows-[1fr] opacity-100'
98 : 'grid-rows-[0fr] opacity-0'
99 "
100 >
101 <div class="overflow-hidden">
102 <div
103 class="transition-transform duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] [&_textarea]:resize-y [&_textarea]:min-h-16 [&_textarea]:max-h-40"
104 :class="isOtherSelected ? 'translate-y-0' : '-translate-y-full'"
105 >
106 <Textarea
107 ref="otherInput"
108 v-model="answers[otherKey]"
109 class="mt-5"
110 variant="outline"
111 :rows="3"
112 :placeholder="otherPlaceholder"
113 :tabindex="isOtherSelected ? 0 : -1"
114 :error="otherError"
115 @update:model-value="otherError = ''"
116 />
117 </div>
118 </div>
119 </div>
120 </template>
121 </div>
122 </div>
123
124 <div class="flex flex-col items-center gap-2">
125 <Button
126 class="w-full"
127 variant="solid"
128 size="sm"
129 :label="isLast ? __('Finish') : __('Next')"
130 :disabled="!canProceed"
131 @click="proceed"
132 />
133 </div>
134 </div>
135 <Button
136 class="mt-auto !bg-transparent !text-ink-gray-5 hover:!text-ink-gray-8"
137 variant="ghost"
138 :label="__('Skip for now')"
139 @click="finish"
140 />
141</template>
142
143<script setup lang="ts">
144import ODLogo from "@/assets/logos/ODLogo.vue";
145import { __ } from "@/translation";
146import { Button, Select, Textarea, TextInput } from "frappe-ui";
147import { computed, nextTick, reactive, ref, watch } from "vue";
148import type { Option, Question } from "./Questionnaire.types";
149
150type Answers = Record<string, string | string[]>;
151
152const props = defineProps<{ questions: Question[] }>();
153const emit = defineEmits<{ submit: [answers: Answers] }>();
154
155const current = ref(0);
156const completed = ref(false);
157const answers = reactive<Answers>({});
158const otherInput = ref<InstanceType<typeof Textarea>>();
159
160const total = computed(() => props.questions.length);
161const question = computed(() => props.questions[current.value] as Question);
162const isLast = computed(() => current.value === total.value - 1);
163
164// Next stays disabled until the current question has an answer.
165const canProceed = computed(() => {
166 const q = question.value;
167 const value = answers[q.key];
168 if (q.type === "text")
169 return !q.required || (typeof value === "string" && value.trim() !== "");
170 if (q.multiple) return Array.isArray(value) && value.length > 0;
171 return typeof value === "string" && value !== "";
172});
173
174// Narrow the discriminated union so the template stays declarative: choice
175// questions render pills, text questions may carry a dropdown beneath them.
176const choiceOptions = computed(() =>
177 question.value.type === "choice" ? question.value.options : []
178);
179const dropdown = computed(() =>
180 question.value.type === "text" ? question.value.dropdown : undefined
181);
182
183// "Other" free-text is stored under a `<key>_other` sibling answer key.
184const OTHER_SUFFIX = "_other";
185const otherKey = computed(() => `${question.value.key}${OTHER_SUFFIX}`);
186const isOtherSelected = computed(() => {
187 const otherOption = choiceOptions.value.find((o) => o.value === "other");
188 return otherOption ? isSelected(otherOption) : false;
189});
190
191const otherPlaceholder = computed(
192 () =>
193 (question.value.type === "choice" && question.value.otherPlaceholder) ||
194 __("Tell us more")
195);
196
197// The asterisk normally rides on the field label; questions whose title is
198// itself the prompt have no label to hang it on.
199const titleRequired = computed(
200 () =>
201 question.value.type === "text" &&
202 !!question.value.required &&
203 !question.value.label
204);
205
206const isOtherFilled = computed(() => {
207 const other = answers[otherKey.value];
208 return typeof other === "string" && other.trim() !== "";
209});
210
211// Cleared on navigation and on toggling "Other" off, so re-opening the box
212// never shows a stale error the user hasn't earned yet.
213const otherError = ref("");
214watch([current, isOtherSelected], () => (otherError.value = ""));
215
216function isSelected(option: Option) {
217 const q = question.value;
218 if (q.type !== "choice") return false;
219 const value = answers[q.key];
220 if (q.multiple) {
221 return Array.isArray(value) && value.includes(option.value);
222 }
223 return value === option.value;
224}
225
226function select(option: Option) {
227 if (completed.value) return;
228 const q = question.value;
229 if (q.type !== "choice") return;
230 const key = q.key;
231 if (q.multiple) {
232 const value = Array.isArray(answers[key])
233 ? [...(answers[key] as string[])]
234 : [];
235 const index = value.indexOf(option.value);
236 if (index === -1) {
237 value.push(option.value);
238 } else {
239 value.splice(index, 1);
240 }
241 answers[key] = value;
242 } else {
243 // A click only sets the selection; advancing is always via the Next button.
244 answers[key] = option.value;
245 }
246
247 if (!isOtherSelected.value) {
248 delete answers[otherKey.value];
249 } else if (option.value === "other") {
250 nextTick(() => otherInput.value?.focus({ preventScroll: true }));
251 }
252}
253
254function advance() {
255 if (current.value < total.value - 1) current.value += 1;
256}
257
258function back() {
259 if (current.value > 0) current.value -= 1;
260}
261
262function proceed() {
263 if (!canProceed.value) return;
264 // "Other" stays selectable with Next enabled; the ask happens on click.
265 if (isOtherSelected.value && !isOtherFilled.value) {
266 otherError.value = __("Add a few words so we can continue");
267 otherInput.value?.focus({ preventScroll: true });
268 return;
269 }
270 if (isLast.value) finish();
271 else advance();
272}
273
274function finish() {
275 if (completed.value) return;
276 completed.value = true;
277 setTimeout(() => emit("submit", { ...answers }), 700);
278}
279</script>
280