feat(tutorial): implement comprehensive tutorial system with step guidance
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m9s
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m9s
- Added tutorial controller to manage tutorial state and progress. - Introduced HelpButton component for contextual help throughout the application. - Created various tutorial steps for onboarding and feature guidance. - Integrated tutorial prompts in multiple components (ChildrenListView, LoginButton, ScheduleModal, etc.) to enhance user experience. - Implemented logic to show tutorials based on user actions and state. - Added functionality to dismiss and skip tutorial sessions. - Established a mechanism to hydrate tutorial state from user profile.
This commit is contained in:
@@ -27,6 +27,7 @@ import {
|
||||
triggerRoutineAsParent,
|
||||
} from '@/common/api'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import { maybeShow as tutorialMaybeShow } from '@/tutorial/controller'
|
||||
import '@/assets/styles.css'
|
||||
import type {
|
||||
Task,
|
||||
@@ -687,6 +688,36 @@ function openChoreMenu(taskId: string, e: MouseEvent) {
|
||||
menuPosition.value = { top: rect.bottom, left: rect.right - 140 }
|
||||
}
|
||||
activeMenuFor.value = taskId
|
||||
tutorialMaybeShow(
|
||||
'chore-kebab',
|
||||
() => document.querySelector('.kebab-menu') as HTMLElement | null,
|
||||
)
|
||||
const items: ChildTask[] = childChoreListRef.value?.items ?? []
|
||||
const task = items.find((t) => t.id === taskId)
|
||||
if (task) {
|
||||
if (isChoreExpired(task)) {
|
||||
nextTick(() => {
|
||||
tutorialMaybeShow(
|
||||
'chore-kebab-extend-time',
|
||||
() =>
|
||||
Array.from(document.querySelectorAll('.kebab-menu .menu-item')).find((el) =>
|
||||
/extend\s*time/i.test(el.textContent || ''),
|
||||
) as HTMLElement | null,
|
||||
)
|
||||
})
|
||||
}
|
||||
if (isChoreCompletedToday(task)) {
|
||||
nextTick(() => {
|
||||
tutorialMaybeShow(
|
||||
'chore-kebab-reset',
|
||||
() =>
|
||||
Array.from(document.querySelectorAll('.kebab-menu .menu-item')).find((el) =>
|
||||
/^reset/i.test((el.textContent || '').trim()),
|
||||
) as HTMLElement | null,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeChoreMenu() {
|
||||
@@ -1002,6 +1033,13 @@ onMounted(async () => {
|
||||
child.value = data
|
||||
tasks.value = data.tasks || []
|
||||
rewards.value = data.rewards || []
|
||||
// Fire the per-child overview tour (chains into assign-* steps).
|
||||
nextTick(() => {
|
||||
tutorialMaybeShow(
|
||||
'select-child',
|
||||
() => document.querySelector('.assign-buttons') as HTMLElement | null,
|
||||
)
|
||||
})
|
||||
}
|
||||
loading.value = false
|
||||
if (scrollToId) {
|
||||
@@ -1028,6 +1066,36 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Fire status-badge tutorials when those badges first render for any chore.
|
||||
watch(
|
||||
() => {
|
||||
const items: ChildTask[] = childChoreListRef.value?.items ?? []
|
||||
return items.map((t) => ({ id: t.id, expired: isChoreExpired(t), pending: isChorePending(t) }))
|
||||
},
|
||||
(list) => {
|
||||
if (list.some((t) => t.expired)) {
|
||||
nextTick(() => {
|
||||
tutorialMaybeShow(
|
||||
'status-too-late',
|
||||
() =>
|
||||
Array.from(document.querySelectorAll('.chore-stamp')).find(
|
||||
(el) => (el.textContent || '').trim() === 'TOO LATE',
|
||||
) as HTMLElement | null,
|
||||
)
|
||||
})
|
||||
}
|
||||
if (list.some((t) => t.pending)) {
|
||||
nextTick(() => {
|
||||
tutorialMaybeShow(
|
||||
'status-pending',
|
||||
() => document.querySelector('.chore-stamp.pending-stamp') as HTMLElement | null,
|
||||
)
|
||||
})
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
eventBus.off('child_task_triggered', handleTaskTriggered)
|
||||
eventBus.off('child_reward_triggered', handleRewardTriggered)
|
||||
|
||||
@@ -35,10 +35,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ItemList from '../shared/ItemList.vue'
|
||||
import MessageBlock from '../shared/MessageBlock.vue'
|
||||
import { maybeShow as tutorialMaybeShow, tutorialReady } from '@/tutorial/controller'
|
||||
import type {
|
||||
PendingConfirmation,
|
||||
Event,
|
||||
@@ -87,6 +88,15 @@ function handleChoreConfirmation(event: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
watch([notificationListCountRef, tutorialReady], ([count, ready]) => {
|
||||
if (ready && typeof count === 'number' && count > 0) {
|
||||
tutorialMaybeShow(
|
||||
'notification-click',
|
||||
() => document.querySelector('.notification-view .list-item') as HTMLElement | null,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
eventBus.on('child_reward_request', handleRewardRequest)
|
||||
eventBus.on('child_chore_confirmation', handleChoreConfirmation)
|
||||
|
||||
@@ -95,6 +95,54 @@
|
||||
<button class="btn btn-primary" @click="closeDeleteError">Close</button>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
|
||||
<!-- Help / Tutorial section -->
|
||||
<section class="help-section" aria-labelledby="help-heading">
|
||||
<h3 id="help-heading" class="help-heading">Help</h3>
|
||||
<label class="help-row">
|
||||
<span class="help-row-text">
|
||||
<span class="help-row-label">Show tutorial tips</span>
|
||||
<span class="help-row-desc">Show helpful tips as I use the app.</span>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="help-toggle"
|
||||
:checked="tutorialEnabled"
|
||||
@change="onToggleTutorial"
|
||||
aria-label="Show tutorial tips"
|
||||
/>
|
||||
</label>
|
||||
<button type="button" class="btn-link btn-link-space" @click="openRestartConfirm">
|
||||
Restart tutorial
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- Restart confirmation -->
|
||||
<ModalDialog
|
||||
v-if="showRestartConfirm"
|
||||
title="Restart tutorial?"
|
||||
@close="showRestartConfirm = false"
|
||||
>
|
||||
<div class="modal-message">
|
||||
Start the tour again from the beginning? You'll see the tips again as you use the app.
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" @click="showRestartConfirm = false">Cancel</button>
|
||||
<button class="btn btn-primary" @click="confirmRestartTutorial">Restart</button>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
|
||||
<!-- Restart success -->
|
||||
<ModalDialog
|
||||
v-if="showRestartSuccess"
|
||||
title="Tour reset"
|
||||
@close="showRestartSuccess = false"
|
||||
>
|
||||
<div class="modal-message">Tour reset — here we go!</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" @click="showRestartSuccess = false">OK</button>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -113,6 +161,11 @@ import {
|
||||
import { parseErrorResponse, isEmailValid } from '@/common/api'
|
||||
import { ALREADY_MARKED } from '@/common/errorCodes'
|
||||
import { logoutUser, suppressForceLogout } from '@/stores/auth'
|
||||
import {
|
||||
tutorialEnabled,
|
||||
setTutorialEnabled,
|
||||
resetAllProgress,
|
||||
} from '@/tutorial/controller'
|
||||
import '@/assets/styles.css'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -136,6 +189,25 @@ const deleteErrorMessage = ref('')
|
||||
const pushError = ref('')
|
||||
const pushPermissionDenied = ref(false)
|
||||
|
||||
// Tutorial controls
|
||||
const showRestartConfirm = ref(false)
|
||||
const showRestartSuccess = ref(false)
|
||||
|
||||
async function onToggleTutorial(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
await setTutorialEnabled(target.checked)
|
||||
}
|
||||
|
||||
function openRestartConfirm() {
|
||||
showRestartConfirm.value = true
|
||||
}
|
||||
|
||||
async function confirmRestartTutorial() {
|
||||
showRestartConfirm.value = false
|
||||
await resetAllProgress()
|
||||
showRestartSuccess.value = true
|
||||
}
|
||||
|
||||
const initialData = ref<{
|
||||
image_id: string | null
|
||||
first_name: string
|
||||
@@ -500,4 +572,45 @@ function closeDeleteError() {
|
||||
outline: none;
|
||||
border-color: var(--btn-primary, #4a90e2);
|
||||
}
|
||||
|
||||
.help-section {
|
||||
margin-top: 1.4rem;
|
||||
padding-top: 1.2rem;
|
||||
border-top: 1px solid var(--form-input-border, #cbd5e1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.help-heading {
|
||||
margin: 0 0 0.2rem;
|
||||
font-size: 1rem;
|
||||
color: var(--form-heading, #667eea);
|
||||
}
|
||||
.help-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.help-row-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.help-row-label {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #222);
|
||||
}
|
||||
.help-row-desc {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary, #888);
|
||||
}
|
||||
.help-toggle {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-top: 2px;
|
||||
accent-color: var(--btn-primary, #667eea);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ItemList from '../shared/ItemList.vue'
|
||||
import MessageBlock from '@/components/shared/MessageBlock.vue'
|
||||
@@ -47,6 +47,11 @@ import type { Reward } from '@/common/models'
|
||||
import { REWARD_FIELDS } from '@/common/models'
|
||||
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import {
|
||||
maybeShow as tutorialMaybeShow,
|
||||
markStepSeen as tutorialMark,
|
||||
tutorialReady,
|
||||
} from '@/tutorial/controller'
|
||||
|
||||
const $router = useRouter()
|
||||
|
||||
@@ -62,6 +67,15 @@ function handleRewardModified(event: any) {
|
||||
}
|
||||
}
|
||||
|
||||
watch([rewardCountRef, tutorialReady], ([count, ready]) => {
|
||||
if (!ready) return
|
||||
if (count === 0) {
|
||||
tutorialMaybeShow('create-reward', () => document.querySelector('.fab') as HTMLElement | null)
|
||||
} else if (typeof count === 'number' && count > 0) {
|
||||
void tutorialMark('has-created-reward')
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
eventBus.on('reward_modified', handleRewardModified)
|
||||
})
|
||||
|
||||
@@ -122,6 +122,7 @@ import EntityEditForm from '@/components/shared/EntityEditForm.vue'
|
||||
import ImagePicker from '@/components/utils/ImagePicker.vue'
|
||||
import { getCachedImageUrl } from '@/common/imageCache'
|
||||
import type { RoutineItem } from '@/common/models'
|
||||
import { maybeShow as tutorialMaybeShow } from '@/tutorial/controller'
|
||||
import '@/assets/styles.css'
|
||||
|
||||
const props = defineProps<{ id?: string }>()
|
||||
@@ -161,6 +162,13 @@ const draggingIdx = ref<number | null>(null)
|
||||
const dragOverIdx = ref<number | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!isEdit.value) {
|
||||
tutorialMaybeShow('create-routine')
|
||||
tutorialMaybeShow(
|
||||
'create-routine-add-task',
|
||||
() => document.querySelector('.add-task-trigger') as HTMLElement | null,
|
||||
)
|
||||
}
|
||||
if (isEdit.value && props.id) {
|
||||
loading.value = true
|
||||
try {
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount, onUnmounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
|
||||
import { isParentAuthenticated } from '../../stores/auth'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import {
|
||||
maybeShow as tutorialMaybeShow,
|
||||
markStepSeen as tutorialMark,
|
||||
tutorialReady,
|
||||
} from '@/tutorial/controller'
|
||||
import type {
|
||||
Child,
|
||||
ChildModifiedEventPayload,
|
||||
@@ -150,6 +155,22 @@ const createChild = () => {
|
||||
router.push({ name: 'CreateChild' })
|
||||
}
|
||||
|
||||
function maybeTriggerCreateChildTutorial() {
|
||||
if (!isParentAuthenticated.value) return
|
||||
if (loading.value) return
|
||||
if (!tutorialReady.value) return
|
||||
if (children.value.length === 0) {
|
||||
tutorialMaybeShow('create-child', () => document.querySelector('.fab') as HTMLElement | null)
|
||||
} else {
|
||||
void tutorialMark('has-created-child')
|
||||
}
|
||||
}
|
||||
|
||||
watch([tutorialReady, loading, children, isParentAuthenticated], maybeTriggerCreateChildTutorial, {
|
||||
immediate: false,
|
||||
deep: true,
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
eventBus.on('child_modified', handleChildModified)
|
||||
eventBus.on('child_task_triggered', handleChildTaskTriggered)
|
||||
@@ -158,6 +179,7 @@ onMounted(async () => {
|
||||
const listPromise = fetchChildren()
|
||||
listPromise.then((list) => {
|
||||
children.value = list
|
||||
maybeTriggerCreateChildTutorial()
|
||||
})
|
||||
// listen for outside clicks to auto-close any open kebab menu
|
||||
document.addEventListener('click', onDocClick, true)
|
||||
@@ -214,6 +236,10 @@ const selectChild = (childId: string | number) => {
|
||||
const openMenu = (childId: string | number, evt?: Event) => {
|
||||
evt?.stopPropagation()
|
||||
activeMenuFor.value = childId
|
||||
tutorialMaybeShow(
|
||||
'child-kebab',
|
||||
() => document.querySelector('.kebab-menu') as HTMLElement | null,
|
||||
)
|
||||
}
|
||||
const closeMenu = () => {
|
||||
activeMenuFor.value = null
|
||||
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
isPushOptedOut,
|
||||
ensurePushSubscriptionSynced,
|
||||
} from '@/services/pushSubscription'
|
||||
import {
|
||||
hydrateFromProfile as hydrateTutorial,
|
||||
maybeShow as tutorialMaybeShow,
|
||||
} from '@/tutorial/controller'
|
||||
|
||||
const router = useRouter()
|
||||
const show = ref(false)
|
||||
@@ -57,6 +61,11 @@ async function fetchUserProfile() {
|
||||
userImageId.value = data.image_id || null
|
||||
userFirstName.value = data.first_name || ''
|
||||
userEmail.value = data.email || ''
|
||||
hydrateTutorial({
|
||||
tutorial_enabled: data.tutorial_enabled,
|
||||
tutorial_progress: data.tutorial_progress,
|
||||
})
|
||||
void maybeShowSetupPinTutorial()
|
||||
|
||||
// Update avatar initial
|
||||
avatarInitial.value = userFirstName.value ? userFirstName.value.charAt(0).toUpperCase() : '?'
|
||||
@@ -83,6 +92,19 @@ async function fetchUserProfile() {
|
||||
}
|
||||
}
|
||||
|
||||
async function maybeShowSetupPinTutorial() {
|
||||
try {
|
||||
const res = await fetch('/api/user/has-pin', { credentials: 'include' })
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
if (!data.has_pin) {
|
||||
tutorialMaybeShow('setup-parent-pin', () => avatarButtonRef.value)
|
||||
}
|
||||
} catch {
|
||||
// Silent: tutorial just won't fire.
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAvatarImages(imageId: string) {
|
||||
try {
|
||||
const blob = await getCachedImageBlob(imageId)
|
||||
|
||||
@@ -161,10 +161,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import ModalDialog from './ModalDialog.vue'
|
||||
import TimePickerPopover from './TimePickerPopover.vue'
|
||||
import DateInputField from './DateInputField.vue'
|
||||
import { maybeShow as tutorialMaybeShow } from '@/tutorial/controller'
|
||||
import {
|
||||
setChoreSchedule,
|
||||
deleteChoreSchedule,
|
||||
@@ -258,6 +259,13 @@ const intervalTime = ref<TimeValue>({
|
||||
const saving = ref(false)
|
||||
const errorMsg = ref<string | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
tutorialMaybeShow(
|
||||
'create-chore-schedule',
|
||||
() => document.querySelector('.day-chips .chip') as HTMLElement | null,
|
||||
)
|
||||
})
|
||||
|
||||
// ── original snapshot (for dirty detection) ──────────────────────────────────
|
||||
|
||||
const origMode = props.schedule?.mode ?? 'days'
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ItemList from '../shared/ItemList.vue'
|
||||
import MessageBlock from '@/components/shared/MessageBlock.vue'
|
||||
@@ -45,6 +45,11 @@ import DeleteModal from '../shared/DeleteModal.vue'
|
||||
import type { Task } from '@/common/models'
|
||||
import { TASK_FIELDS } from '@/common/models'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import {
|
||||
maybeShow as tutorialMaybeShow,
|
||||
markStepSeen as tutorialMark,
|
||||
tutorialReady,
|
||||
} from '@/tutorial/controller'
|
||||
|
||||
const $router = useRouter()
|
||||
const showConfirm = ref(false)
|
||||
@@ -56,6 +61,15 @@ function handleModified() {
|
||||
listRef.value?.refresh()
|
||||
}
|
||||
|
||||
watch([countRef, tutorialReady], ([count, ready]) => {
|
||||
if (!ready) return
|
||||
if (count === 0) {
|
||||
tutorialMaybeShow('create-chore', () => document.querySelector('.fab') as HTMLElement | null)
|
||||
} else if (typeof count === 'number' && count > 0) {
|
||||
void tutorialMark('has-created-chore')
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
eventBus.on('task_modified', handleModified)
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import EntityEditForm from '../shared/EntityEditForm.vue'
|
||||
import { maybeShow as tutorialMaybeShow } from '@/tutorial/controller'
|
||||
import '@/assets/styles.css'
|
||||
|
||||
const props = defineProps<{ id?: string }>()
|
||||
@@ -36,6 +37,7 @@ const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!isEdit.value) tutorialMaybeShow('create-kindness')
|
||||
if (isEdit.value && props.id) {
|
||||
loading.value = true
|
||||
try {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import EntityEditForm from '../shared/EntityEditForm.vue'
|
||||
import { maybeShow as tutorialMaybeShow } from '@/tutorial/controller'
|
||||
import '@/assets/styles.css'
|
||||
|
||||
const props = defineProps<{ id?: string }>()
|
||||
@@ -36,6 +37,7 @@ const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!isEdit.value) tutorialMaybeShow('create-penalty')
|
||||
if (isEdit.value && props.id) {
|
||||
loading.value = true
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick, computed } from 'vue'
|
||||
import { getCachedImageUrl } from '@/common/imageCache'
|
||||
import { maybeShow as tutorialMaybeShow } from '@/tutorial/controller'
|
||||
import '@/assets/styles.css'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -159,6 +160,10 @@ onMounted(async () => {
|
||||
} finally {
|
||||
loadingImages.value = false
|
||||
}
|
||||
tutorialMaybeShow(
|
||||
'create-chore-image',
|
||||
() => document.querySelector('.icon-btn') as HTMLElement | null,
|
||||
)
|
||||
})
|
||||
|
||||
async function resizeImageFile(
|
||||
|
||||
Reference in New Issue
Block a user