feat(tutorial): implement comprehensive tutorial system with step guidance
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m9s
All checks were successful
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:
@@ -1,10 +1,14 @@
|
||||
<template>
|
||||
<BackendEventsListener />
|
||||
<router-view />
|
||||
<TutorialOverlay />
|
||||
<TutorialChildModeOffer />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BackendEventsListener from '@/components/BackendEventsListener.vue'
|
||||
import TutorialOverlay from '@/tutorial/TutorialOverlay.vue'
|
||||
import TutorialChildModeOffer from '@/tutorial/TutorialChildModeOffer.vue'
|
||||
import { checkAuth } from '@/stores/auth'
|
||||
|
||||
checkAuth()
|
||||
|
||||
@@ -116,6 +116,8 @@ export interface User {
|
||||
role: string
|
||||
timezone: string | null
|
||||
email_digest_enabled: boolean
|
||||
tutorial_enabled?: boolean
|
||||
tutorial_progress?: Record<string, boolean>
|
||||
}
|
||||
|
||||
export interface Child {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
<button v-show="showBack" class="back-btn" @click="handleBack" tabindex="0">← Back</button>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<div class="end-button-container help-button-container">
|
||||
<HelpButton />
|
||||
</div>
|
||||
<div class="end-button-container">
|
||||
<LoginButton />
|
||||
</div>
|
||||
@@ -19,6 +22,7 @@
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { computed } from 'vue'
|
||||
import LoginButton from '../components/shared/LoginButton.vue'
|
||||
import HelpButton from '@/tutorial/HelpButton.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -69,6 +73,14 @@ const showBack = computed(() => route.path !== '/child')
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.help-button-container {
|
||||
width: 44px;
|
||||
min-width: 44px;
|
||||
max-width: 44px;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
width: 65px;
|
||||
min-width: 65px;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import LoginButton from '../components/shared/LoginButton.vue'
|
||||
import HelpButton from '@/tutorial/HelpButton.vue'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import type {
|
||||
Event,
|
||||
@@ -174,6 +175,9 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</nav>
|
||||
<div v-else class="spacer"></div>
|
||||
<div class="end-button-container help-button-container">
|
||||
<HelpButton />
|
||||
</div>
|
||||
<div class="end-button-container">
|
||||
<LoginButton />
|
||||
</div>
|
||||
@@ -222,6 +226,14 @@ onUnmounted(() => {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.help-button-container {
|
||||
width: 44px;
|
||||
min-width: 44px;
|
||||
max-width: 44px;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
width: 65px;
|
||||
min-width: 65px;
|
||||
|
||||
92
frontend/src/tutorial/HelpButton.vue
Normal file
92
frontend/src/tutorial/HelpButton.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<button
|
||||
v-if="visible"
|
||||
type="button"
|
||||
class="help-btn"
|
||||
aria-label="Show help for this screen"
|
||||
@click="onClick"
|
||||
title="Show help"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import {
|
||||
activeStep,
|
||||
maybeShow,
|
||||
tutorialEnabled,
|
||||
tutorialProgress,
|
||||
} from './controller'
|
||||
|
||||
// Map route names to the tutorial step that should re-fire when the user taps `?`.
|
||||
// Keep this list lean — only routes that have a tutorial step actually wired.
|
||||
const routeToStep: Record<string, string> = {
|
||||
ParentChildrenListView: 'create-child',
|
||||
ChoreView: 'create-chore',
|
||||
RewardView: 'create-reward',
|
||||
NotificationView: 'notification-click',
|
||||
ParentView: 'select-child',
|
||||
CreateChore: 'create-chore-image',
|
||||
EditChore: 'create-chore-image',
|
||||
CreateKindness: 'create-kindness',
|
||||
CreatePenalty: 'create-penalty',
|
||||
CreateRoutine: 'create-routine',
|
||||
EditRoutine: 'create-routine-add-task',
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const targetStepId = computed<string | null>(() => {
|
||||
const name = typeof route.name === 'string' ? route.name : String(route.name ?? '')
|
||||
return routeToStep[name] ?? null
|
||||
})
|
||||
|
||||
const visible = computed(() => {
|
||||
if (!tutorialEnabled.value) return false
|
||||
return targetStepId.value !== null
|
||||
})
|
||||
|
||||
function onClick() {
|
||||
const id = targetStepId.value
|
||||
if (!id) return
|
||||
// Clear any active step so the manual re-fire wins.
|
||||
activeStep.value = null
|
||||
// Temporarily clear local "seen" so the controller re-shows it. The server
|
||||
// state is left alone; on dismiss `markStepSeen` simply no-ops at the server.
|
||||
if (tutorialProgress.value[id]) {
|
||||
const progress = { ...tutorialProgress.value }
|
||||
delete progress[id]
|
||||
tutorialProgress.value = progress
|
||||
}
|
||||
maybeShow(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.help-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 0;
|
||||
background: var(--btn-secondary, rgba(255, 255, 255, 0.85));
|
||||
color: var(--btn-primary, #667eea);
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.help-btn:hover {
|
||||
background: var(--btn-secondary-hover, #e2e8f0);
|
||||
}
|
||||
.help-btn:focus-visible {
|
||||
outline: 2px solid var(--primary, #667eea);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
100
frontend/src/tutorial/TutorialChildModeOffer.vue
Normal file
100
frontend/src/tutorial/TutorialChildModeOffer.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<ModalDialog v-if="visible" title="Want to see what your child sees?" @close="onLater">
|
||||
<div class="modal-message">
|
||||
Take a quick peek at child mode so you can show them how it works.
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" @click="onNoThanks">No thanks</button>
|
||||
<button class="btn btn-secondary" @click="onLater">Maybe later</button>
|
||||
<button class="btn btn-primary" @click="onYes">Yes, show me</button>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ModalDialog from '@/components/shared/ModalDialog.vue'
|
||||
import {
|
||||
tutorialEnabled,
|
||||
tutorialProgress,
|
||||
tutorialReady,
|
||||
markStepSeen,
|
||||
maybeShow,
|
||||
} from './controller'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// "Maybe later" is session-only: don't persist, just don't re-prompt this session.
|
||||
const dismissedThisSession = ref(false)
|
||||
|
||||
const prereqsMet = computed(() => {
|
||||
const p = tutorialProgress.value
|
||||
return !!p['has-created-child'] && !!p['has-created-chore'] && !!p['has-created-reward']
|
||||
})
|
||||
|
||||
const visible = computed(() => {
|
||||
if (!tutorialEnabled.value) return false
|
||||
if (!tutorialReady.value) return false
|
||||
if (dismissedThisSession.value) return false
|
||||
if (tutorialProgress.value['child-mode-tour-offer']) return false
|
||||
return prereqsMet.value
|
||||
})
|
||||
|
||||
// Avoid showing the modal on top of an active coach mark — wait for it to clear.
|
||||
const showWhenIdle = ref(false)
|
||||
watch(visible, (v) => {
|
||||
showWhenIdle.value = v
|
||||
})
|
||||
|
||||
function onYes() {
|
||||
void markStepSeen('child-mode-tour-offer')
|
||||
router.push('/child')
|
||||
// Fire the overview step once we're on /child. anchorSelector resolves the anchor.
|
||||
setTimeout(() => maybeShow('child-mode-overview'), 400)
|
||||
}
|
||||
|
||||
function onLater() {
|
||||
dismissedThisSession.value = true
|
||||
}
|
||||
|
||||
function onNoThanks() {
|
||||
void markStepSeen('child-mode-tour-offer')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-message {
|
||||
font-size: 1rem;
|
||||
color: var(--text-primary, #222);
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.btn {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.95rem;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: var(--btn-secondary, #f3f3f3);
|
||||
color: var(--btn-secondary-text, #666);
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: var(--btn-secondary-hover, #e2e8f0);
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--btn-primary, #667eea);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--btn-primary-hover, #5a67d8);
|
||||
}
|
||||
</style>
|
||||
402
frontend/src/tutorial/TutorialOverlay.vue
Normal file
402
frontend/src/tutorial/TutorialOverlay.vue
Normal file
@@ -0,0 +1,402 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="step"
|
||||
class="tutorial-root"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="titleId"
|
||||
:aria-describedby="bodyId"
|
||||
>
|
||||
<!-- Spotlight: four dim rectangles forming a hole around the anchor. -->
|
||||
<template v-if="anchorRect">
|
||||
<div class="dim" :style="dimTopStyle" />
|
||||
<div class="dim" :style="dimBottomStyle" />
|
||||
<div class="dim" :style="dimLeftStyle" />
|
||||
<div class="dim" :style="dimRightStyle" />
|
||||
<div class="ring" :style="ringStyle" aria-hidden="true" />
|
||||
</template>
|
||||
<div v-else class="dim dim-full" />
|
||||
|
||||
<!-- Coach mark card -->
|
||||
<div
|
||||
ref="cardEl"
|
||||
class="card"
|
||||
:class="{ 'card-sheet': isMobile, 'card-floating': !isMobile && !!anchorRect, 'card-center': !anchorRect && !isMobile }"
|
||||
:style="cardStyle"
|
||||
@keydown="handleKeydown"
|
||||
tabindex="-1"
|
||||
>
|
||||
<h3 :id="titleId" class="title">{{ step.def.title }}</h3>
|
||||
<p :id="bodyId" class="body" aria-live="polite">{{ step.def.body }}</p>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn btn-skip" @click="onSkip" ref="skipBtn">
|
||||
Skip tour
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" @click="onPrimary" ref="primaryBtn">
|
||||
{{ primaryLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { activeStep, dismissActive, skipSession, type ActiveStep } from './controller'
|
||||
|
||||
const MOBILE_BREAKPOINT = 600
|
||||
const CARD_MAX_WIDTH = 320
|
||||
const CARD_MARGIN = 12
|
||||
|
||||
const cardEl = ref<HTMLElement | null>(null)
|
||||
const primaryBtn = ref<HTMLElement | null>(null)
|
||||
const skipBtn = ref<HTMLElement | null>(null)
|
||||
const anchorRect = ref<DOMRect | null>(null)
|
||||
const cardSize = ref<{ width: number; height: number }>({ width: CARD_MAX_WIDTH, height: 160 })
|
||||
const viewport = ref<{ w: number; h: number }>({
|
||||
w: typeof window !== 'undefined' ? window.innerWidth : 1024,
|
||||
h: typeof window !== 'undefined' ? window.innerHeight : 768,
|
||||
})
|
||||
const reducedMotion = ref(
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window.matchMedia === 'function' &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
)
|
||||
|
||||
const step = computed<ActiveStep | null>(() => activeStep.value)
|
||||
const titleId = 'tutorial-title'
|
||||
const bodyId = 'tutorial-body'
|
||||
|
||||
const isMobile = computed(() => viewport.value.w <= MOBILE_BREAKPOINT)
|
||||
|
||||
const primaryLabel = computed(() => {
|
||||
const def = step.value?.def
|
||||
if (!def) return 'Got it'
|
||||
if (def.ctaLabel) return def.ctaLabel
|
||||
return def.next ? 'Next' : 'Got it'
|
||||
})
|
||||
|
||||
function resolveAnchor(): HTMLElement | null {
|
||||
const s = step.value
|
||||
if (!s || !s.anchor) return null
|
||||
return typeof s.anchor === 'function' ? s.anchor() : s.anchor
|
||||
}
|
||||
|
||||
function measureAnchor() {
|
||||
const el = resolveAnchor()
|
||||
if (!el) {
|
||||
anchorRect.value = null
|
||||
return
|
||||
}
|
||||
const rect = el.getBoundingClientRect()
|
||||
anchorRect.value = rect
|
||||
}
|
||||
|
||||
function measureCard() {
|
||||
const el = cardEl.value
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
cardSize.value = { width: rect.width, height: rect.height }
|
||||
}
|
||||
|
||||
function measureAll() {
|
||||
viewport.value = { w: window.innerWidth, h: window.innerHeight }
|
||||
measureAnchor()
|
||||
measureCard()
|
||||
}
|
||||
|
||||
const dimTopStyle = computed(() => {
|
||||
const r = anchorRect.value
|
||||
if (!r) return {}
|
||||
return {
|
||||
top: '0px',
|
||||
left: '0px',
|
||||
width: '100%',
|
||||
height: `${Math.max(0, r.top - 6)}px`,
|
||||
}
|
||||
})
|
||||
const dimBottomStyle = computed(() => {
|
||||
const r = anchorRect.value
|
||||
if (!r) return {}
|
||||
return {
|
||||
top: `${r.bottom + 6}px`,
|
||||
left: '0px',
|
||||
width: '100%',
|
||||
height: `${Math.max(0, viewport.value.h - r.bottom - 6)}px`,
|
||||
}
|
||||
})
|
||||
const dimLeftStyle = computed(() => {
|
||||
const r = anchorRect.value
|
||||
if (!r) return {}
|
||||
return {
|
||||
top: `${Math.max(0, r.top - 6)}px`,
|
||||
left: '0px',
|
||||
width: `${Math.max(0, r.left - 6)}px`,
|
||||
height: `${r.height + 12}px`,
|
||||
}
|
||||
})
|
||||
const dimRightStyle = computed(() => {
|
||||
const r = anchorRect.value
|
||||
if (!r) return {}
|
||||
return {
|
||||
top: `${Math.max(0, r.top - 6)}px`,
|
||||
left: `${r.right + 6}px`,
|
||||
width: `${Math.max(0, viewport.value.w - r.right - 6)}px`,
|
||||
height: `${r.height + 12}px`,
|
||||
}
|
||||
})
|
||||
|
||||
const ringStyle = computed(() => {
|
||||
const r = anchorRect.value
|
||||
if (!r) return {}
|
||||
return {
|
||||
top: `${r.top - 6}px`,
|
||||
left: `${r.left - 6}px`,
|
||||
width: `${r.width + 12}px`,
|
||||
height: `${r.height + 12}px`,
|
||||
}
|
||||
})
|
||||
|
||||
const cardStyle = computed(() => {
|
||||
if (isMobile.value) return {}
|
||||
const r = anchorRect.value
|
||||
if (!r) return {}
|
||||
const { w: vw, h: vh } = viewport.value
|
||||
const { width: cw, height: ch } = cardSize.value
|
||||
const preferred = step.value?.def.placement ?? 'auto'
|
||||
|
||||
const spaceBelow = vh - r.bottom - CARD_MARGIN
|
||||
const spaceAbove = r.top - CARD_MARGIN
|
||||
const placeBelow =
|
||||
preferred === 'below' ||
|
||||
(preferred !== 'above' && spaceBelow >= ch + CARD_MARGIN) ||
|
||||
spaceAbove < ch + CARD_MARGIN
|
||||
|
||||
const top = placeBelow ? r.bottom + CARD_MARGIN : Math.max(CARD_MARGIN, r.top - ch - CARD_MARGIN)
|
||||
|
||||
const anchorCenterX = r.left + r.width / 2
|
||||
let left = anchorCenterX - cw / 2
|
||||
left = Math.max(CARD_MARGIN, Math.min(left, vw - cw - CARD_MARGIN))
|
||||
|
||||
return { top: `${top}px`, left: `${left}px` }
|
||||
})
|
||||
|
||||
function trapFocus(e: KeyboardEvent) {
|
||||
if (e.key !== 'Tab') return
|
||||
const focusables = [skipBtn.value, primaryBtn.value].filter(
|
||||
(el): el is HTMLElement => el !== null,
|
||||
)
|
||||
if (focusables.length === 0) return
|
||||
const first = focusables[0]!
|
||||
const last = focusables[focusables.length - 1]!
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onPrimary()
|
||||
return
|
||||
}
|
||||
trapFocus(e)
|
||||
}
|
||||
|
||||
function onPrimary() {
|
||||
dismissActive(true)
|
||||
}
|
||||
|
||||
function onSkip() {
|
||||
skipSession()
|
||||
}
|
||||
|
||||
// Re-measure on scroll/resize while a step is active.
|
||||
function onScroll() {
|
||||
measureAnchor()
|
||||
}
|
||||
function onResize() {
|
||||
measureAll()
|
||||
}
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let rafId: number | null = null
|
||||
|
||||
function startTracking() {
|
||||
window.addEventListener('resize', onResize)
|
||||
window.addEventListener('scroll', onScroll, true)
|
||||
if (typeof ResizeObserver !== 'undefined' && cardEl.value) {
|
||||
resizeObserver = new ResizeObserver(() => measureCard())
|
||||
resizeObserver.observe(cardEl.value)
|
||||
}
|
||||
// rAF for cases where anchor shifts (animated FAB, etc.)
|
||||
const tick = () => {
|
||||
measureAnchor()
|
||||
rafId = requestAnimationFrame(tick)
|
||||
}
|
||||
rafId = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
function stopTracking() {
|
||||
window.removeEventListener('resize', onResize)
|
||||
window.removeEventListener('scroll', onScroll, true)
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
if (rafId !== null) cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
|
||||
watch(
|
||||
step,
|
||||
async (val) => {
|
||||
if (!val) {
|
||||
stopTracking()
|
||||
anchorRect.value = null
|
||||
return
|
||||
}
|
||||
await nextTick()
|
||||
measureAll()
|
||||
startTracking()
|
||||
// Focus primary button for keyboard users (after paint).
|
||||
await nextTick()
|
||||
primaryBtn.value?.focus()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(stopTracking)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tutorial-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1400;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dim {
|
||||
position: fixed;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
pointer-events: auto;
|
||||
}
|
||||
.dim-full {
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.ring {
|
||||
position: fixed;
|
||||
border: 3px solid var(--primary, #667eea);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.85) inset;
|
||||
pointer-events: none;
|
||||
animation: tutorial-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ring {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tutorial-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.04);
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
position: fixed;
|
||||
background: var(--form-bg, #fff);
|
||||
color: var(--text-primary, #222);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.35);
|
||||
padding: 1rem 1.1rem 0.9rem;
|
||||
pointer-events: auto;
|
||||
max-width: 320px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.card-floating {
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
.card-center {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
.card-sheet {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border-radius: 16px 16px 0 0;
|
||||
padding: 1rem 1.1rem calc(1rem + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 0 0.4rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: var(--form-heading, #667eea);
|
||||
}
|
||||
|
||||
.body {
|
||||
margin: 0 0 0.9rem;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.4;
|
||||
color: var(--text-primary, #222);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.95rem;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-skip {
|
||||
background: var(--btn-secondary, #f3f3f3);
|
||||
color: var(--btn-secondary-text, #666);
|
||||
}
|
||||
.btn-skip:hover {
|
||||
background: var(--btn-secondary-hover, #e2e8f0);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--btn-primary, #667eea);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--btn-primary-hover, #5a67d8);
|
||||
}
|
||||
|
||||
.btn:focus-visible {
|
||||
outline: 2px solid var(--primary, #667eea);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
193
frontend/src/tutorial/controller.ts
Normal file
193
frontend/src/tutorial/controller.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import { stepRegistry, type StepDef } from './steps'
|
||||
|
||||
export type AnchorSource = HTMLElement | (() => HTMLElement | null) | null
|
||||
|
||||
export interface ActiveStep {
|
||||
def: StepDef
|
||||
anchor: AnchorSource
|
||||
}
|
||||
|
||||
export const tutorialEnabled = ref(true)
|
||||
export const tutorialProgress = ref<Record<string, boolean>>({})
|
||||
export const tutorialReady = ref(false)
|
||||
export const activeStep = ref<ActiveStep | null>(null)
|
||||
export const sessionSkipped = ref(false)
|
||||
|
||||
const queue: ActiveStep[] = []
|
||||
let hydrated = false
|
||||
|
||||
function applyDevOverrides() {
|
||||
if (typeof window === 'undefined') return
|
||||
if (!import.meta.env.DEV) return
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (params.get('tutorial') === 'off') {
|
||||
tutorialEnabled.value = false
|
||||
}
|
||||
if (params.get('tutorial') === 'reset') {
|
||||
void resetAllProgress()
|
||||
}
|
||||
}
|
||||
|
||||
export function hydrateFromProfile(profile: {
|
||||
tutorial_enabled?: boolean
|
||||
tutorial_progress?: Record<string, boolean>
|
||||
}) {
|
||||
if (typeof profile.tutorial_enabled === 'boolean') {
|
||||
tutorialEnabled.value = profile.tutorial_enabled
|
||||
}
|
||||
if (profile.tutorial_progress && typeof profile.tutorial_progress === 'object') {
|
||||
tutorialProgress.value = { ...profile.tutorial_progress }
|
||||
}
|
||||
tutorialReady.value = true
|
||||
if (!hydrated) {
|
||||
hydrated = true
|
||||
applyDevOverrides()
|
||||
}
|
||||
// Re-evaluate queue: any step now-seen should be dropped.
|
||||
for (let i = queue.length - 1; i >= 0; i--) {
|
||||
const entry = queue[i]
|
||||
if (entry && !shouldShowStep(entry.def.id)) queue.splice(i, 1)
|
||||
}
|
||||
if (activeStep.value && !shouldShowStep(activeStep.value.def.id)) {
|
||||
activeStep.value = null
|
||||
promote()
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldShowStep(id: string): boolean {
|
||||
if (!tutorialEnabled.value) return false
|
||||
if (sessionSkipped.value) return false
|
||||
if (tutorialProgress.value[id]) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function resolveAnchor(anchor: AnchorSource): HTMLElement | null {
|
||||
if (!anchor) return null
|
||||
if (typeof anchor === 'function') return anchor()
|
||||
return anchor
|
||||
}
|
||||
|
||||
function promote() {
|
||||
if (activeStep.value) return
|
||||
while (queue.length > 0) {
|
||||
const next = queue.shift()!
|
||||
if (!shouldShowStep(next.def.id)) continue
|
||||
// If anchor is missing, try the registry fallback selector.
|
||||
if (next.anchor === null && next.def.anchorSelector) {
|
||||
const sel = next.def.anchorSelector
|
||||
next.anchor = () => document.querySelector(sel) as HTMLElement | null
|
||||
}
|
||||
if (next.anchor !== null) {
|
||||
const el = resolveAnchor(next.anchor)
|
||||
if (!el) continue
|
||||
}
|
||||
activeStep.value = next
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a step to be shown. Idempotent: if already seen or session-skipped, no-op.
|
||||
* If anchor is provided but the element is missing at promotion time, the step is dropped.
|
||||
* If anchor is null, the step renders as a centered modal-style card.
|
||||
*/
|
||||
export function maybeShow(id: string, anchor: AnchorSource = null) {
|
||||
if (!stepRegistry[id]) {
|
||||
if (import.meta.env.DEV) console.warn(`[tutorial] Unknown step id: ${id}`)
|
||||
return
|
||||
}
|
||||
if (!shouldShowStep(id)) return
|
||||
if (activeStep.value?.def.id === id) return
|
||||
if (queue.some((q) => q.def.id === id)) return
|
||||
queue.push({ def: stepRegistry[id], anchor })
|
||||
promote()
|
||||
}
|
||||
|
||||
export function dismissActive(markSeen = true) {
|
||||
const current = activeStep.value
|
||||
if (!current) return
|
||||
activeStep.value = null
|
||||
if (markSeen) {
|
||||
void markStepSeen(current.def.id)
|
||||
if (current.def.next) {
|
||||
const nextDef = stepRegistry[current.def.next]
|
||||
if (nextDef && shouldShowStep(nextDef.id)) {
|
||||
queue.unshift({ def: nextDef, anchor: null })
|
||||
}
|
||||
}
|
||||
}
|
||||
promote()
|
||||
}
|
||||
|
||||
export function skipSession() {
|
||||
const current = activeStep.value
|
||||
sessionSkipped.value = true
|
||||
queue.length = 0
|
||||
activeStep.value = null
|
||||
if (current) void markStepSeen(current.def.id)
|
||||
}
|
||||
|
||||
export async function markStepSeen(id: string): Promise<void> {
|
||||
// Optimistic — local first, then persist.
|
||||
if (tutorialProgress.value[id]) return
|
||||
tutorialProgress.value = { ...tutorialProgress.value, [id]: true }
|
||||
try {
|
||||
await fetch('/api/user/tutorial-progress', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ step_id: id, seen: true }),
|
||||
})
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('[tutorial] Failed to persist step', id, e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetAllProgress(): Promise<void> {
|
||||
tutorialProgress.value = {}
|
||||
sessionSkipped.value = false
|
||||
try {
|
||||
await fetch('/api/user/tutorial-progress', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ reset: true }),
|
||||
})
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('[tutorial] Failed to reset progress', e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function setTutorialEnabled(on: boolean): Promise<void> {
|
||||
tutorialEnabled.value = on
|
||||
if (!on) {
|
||||
queue.length = 0
|
||||
activeStep.value = null
|
||||
}
|
||||
try {
|
||||
await fetch('/api/user/tutorial-progress', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ enabled: on }),
|
||||
})
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('[tutorial] Failed to set enabled', e)
|
||||
}
|
||||
}
|
||||
|
||||
let profileUpdatedBound = false
|
||||
export function bindProfileUpdatedListener(refetch: () => Promise<void> | void) {
|
||||
if (profileUpdatedBound) return
|
||||
profileUpdatedBound = true
|
||||
eventBus.on('profile_updated', () => {
|
||||
void refetch()
|
||||
})
|
||||
}
|
||||
|
||||
// When the active step is dismissed externally (e.g. anchor unmounts), drain the queue.
|
||||
watch(activeStep, (val) => {
|
||||
if (val === null) promote()
|
||||
})
|
||||
185
frontend/src/tutorial/steps.ts
Normal file
185
frontend/src/tutorial/steps.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
export type Placement = 'auto' | 'below' | 'above' | 'left' | 'right'
|
||||
|
||||
export interface StepDef {
|
||||
id: string
|
||||
title: string
|
||||
body: string
|
||||
/** Chain another step to fire after this one is dismissed. */
|
||||
next?: string
|
||||
placement?: Placement
|
||||
/** Primary button label. Defaults to "Got it" (or "Next" when `next` is set). */
|
||||
ctaLabel?: string
|
||||
/**
|
||||
* Fallback CSS selector used to resolve the anchor element when a step is
|
||||
* triggered via chaining (no explicit anchor passed). Optional — if not set
|
||||
* and no anchor is provided, the step renders centered with no spotlight.
|
||||
*/
|
||||
anchorSelector?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Tutorial step registry. To add a new step:
|
||||
* 1. Add an entry here.
|
||||
* 2. In the host component that owns the anchor element, call
|
||||
* `tutorial.maybeShow('your-id', anchorRefOrSelectorFn)`.
|
||||
*/
|
||||
export const stepRegistry: Record<string, StepDef> = {
|
||||
// ── Forced 3-step intro ──────────────────────────────────────────────────
|
||||
'setup-parent-pin': {
|
||||
id: 'setup-parent-pin',
|
||||
title: 'Welcome! Set up your parent PIN',
|
||||
body: 'Tap your avatar to set up a 4–6 digit PIN. The PIN keeps parent mode (where you add chores and rewards) safe from little fingers.',
|
||||
placement: 'below',
|
||||
ctaLabel: 'Got it',
|
||||
},
|
||||
'create-child': {
|
||||
id: 'create-child',
|
||||
title: 'Add your first child',
|
||||
body: "Let's add your first kiddo. Tap the + button to create a profile — you can pick a fun avatar together.",
|
||||
placement: 'above',
|
||||
ctaLabel: 'Got it',
|
||||
},
|
||||
'create-chore': {
|
||||
id: 'create-chore',
|
||||
title: 'Create your first chore',
|
||||
body: 'Tap + to add a chore — give it a name, choose points, and pick a picture so your child can spot it easily.',
|
||||
placement: 'above',
|
||||
ctaLabel: 'Got it',
|
||||
},
|
||||
|
||||
// ── Create-flow JIT hints ────────────────────────────────────────────────
|
||||
'create-chore-image': {
|
||||
id: 'create-chore-image',
|
||||
title: 'Pick a picture',
|
||||
body: 'Tap + to choose from your photos, or the camera to snap a new one. A clear picture helps younger kids recognize each chore.',
|
||||
placement: 'above',
|
||||
},
|
||||
'create-chore-schedule': {
|
||||
id: 'create-chore-schedule',
|
||||
title: 'Set when it happens',
|
||||
body: 'Tap the days this chore should appear. Set a deadline if it needs to be done by a certain time, or leave it as Anytime.',
|
||||
placement: 'below',
|
||||
},
|
||||
'create-kindness': {
|
||||
id: 'create-kindness',
|
||||
title: 'Kindness tasks',
|
||||
body: 'Kindness tasks reward thoughtful behavior — sharing, helping a sibling, saying please. Give it a name and points.',
|
||||
placement: 'auto',
|
||||
},
|
||||
'create-penalty': {
|
||||
id: 'create-penalty',
|
||||
title: 'Penalties',
|
||||
body: 'Penalties subtract points when rules are broken. Use them sparingly — they work best alongside lots of positive tasks.',
|
||||
placement: 'auto',
|
||||
},
|
||||
'create-routine': {
|
||||
id: 'create-routine',
|
||||
title: 'Routines',
|
||||
body: "Routines bundle a sequence of small tasks — like a bedtime routine. Add each task below, and your child checks them off one by one.",
|
||||
placement: 'auto',
|
||||
},
|
||||
'create-routine-add-task': {
|
||||
id: 'create-routine-add-task',
|
||||
title: 'Add tasks to the routine',
|
||||
body: 'Tap to add each step. Order them the way you want your child to do them — they can be reordered later by dragging.',
|
||||
placement: 'above',
|
||||
},
|
||||
'create-reward': {
|
||||
id: 'create-reward',
|
||||
title: 'Create your first reward',
|
||||
body: "Rewards are what your child can spend points on — screen time, treats, a special outing. Tap + to add one.",
|
||||
placement: 'above',
|
||||
},
|
||||
|
||||
// ── Per-tab / navigation hints ───────────────────────────────────────────
|
||||
'notification-click': {
|
||||
id: 'notification-click',
|
||||
title: 'Tap to review',
|
||||
body: "We ping you when a chore is finished or a reward is requested. Tap a notification to jump straight to it and approve or reject.",
|
||||
placement: 'below',
|
||||
},
|
||||
|
||||
// ── Per-child view ───────────────────────────────────────────────────────
|
||||
'select-child': {
|
||||
id: 'select-child',
|
||||
title: "This is your child's page",
|
||||
body: 'Here you can assign chores, rewards, and routines, see their points, and review their progress. Scroll down to see each section.',
|
||||
placement: 'auto',
|
||||
next: 'assign-chore',
|
||||
ctaLabel: 'Next',
|
||||
},
|
||||
'assign-chore': {
|
||||
id: 'assign-chore',
|
||||
title: 'Assign chores',
|
||||
body: "Tap to pick which chores this child does. Assigned chores show up in their view. You can give the same chore to multiple kids.",
|
||||
placement: 'above',
|
||||
next: 'assign-reward',
|
||||
ctaLabel: 'Next',
|
||||
anchorSelector: '.assign-buttons button:nth-child(1)',
|
||||
},
|
||||
'assign-reward': {
|
||||
id: 'assign-reward',
|
||||
title: 'Assign rewards',
|
||||
body: "Pick which rewards this child can spend points on. They'll only see rewards you assign here.",
|
||||
placement: 'above',
|
||||
next: 'assign-routine',
|
||||
ctaLabel: 'Next',
|
||||
anchorSelector: '.assign-buttons button:nth-child(3)',
|
||||
},
|
||||
'assign-routine': {
|
||||
id: 'assign-routine',
|
||||
title: 'Assign routines',
|
||||
body: "Routines you assign show up on this child’s page as a checklist they can work through, step by step.",
|
||||
placement: 'above',
|
||||
anchorSelector: '.assign-buttons button:nth-child(2)',
|
||||
},
|
||||
|
||||
// ── Kebab menus ──────────────────────────────────────────────────────────
|
||||
'child-kebab': {
|
||||
id: 'child-kebab',
|
||||
title: "Quick actions",
|
||||
body: "Edit the child's name or photo, clear their points, or remove them entirely. These actions only affect this one kid.",
|
||||
placement: 'below',
|
||||
},
|
||||
'chore-kebab': {
|
||||
id: 'chore-kebab',
|
||||
title: 'Chore actions',
|
||||
body: 'Edit points, change when the chore appears, or open the schedule. New options can appear here depending on the chore’s status.',
|
||||
placement: 'auto',
|
||||
},
|
||||
'chore-kebab-extend-time': {
|
||||
id: 'chore-kebab-extend-time',
|
||||
title: 'Extend the deadline',
|
||||
body: "When a chore runs late, this option lets you give your child more time for the rest of today only — no need to reschedule.",
|
||||
placement: 'auto',
|
||||
},
|
||||
'chore-kebab-reset': {
|
||||
id: 'chore-kebab-reset',
|
||||
title: 'Reset a completed chore',
|
||||
body: "Already-completed today? Reset lets your child do it again today (points are kept). Useful for chores you want done twice.",
|
||||
placement: 'auto',
|
||||
},
|
||||
|
||||
// ── Child-mode tour (parent learns to teach) ─────────────────────────────
|
||||
'child-mode-overview': {
|
||||
id: 'child-mode-overview',
|
||||
title: "What your child sees",
|
||||
body: "This is your child's home screen. They tap their own card to see their chores, routines, and rewards — just like you've been setting up.",
|
||||
placement: 'auto',
|
||||
anchorSelector: '.grid',
|
||||
},
|
||||
|
||||
// ── Status badges ────────────────────────────────────────────────────────
|
||||
'status-too-late': {
|
||||
id: 'status-too-late',
|
||||
title: 'Too late',
|
||||
body: "This chore passed its deadline today. You can extend it from the kebab menu, or let it expire and try again tomorrow.",
|
||||
placement: 'auto',
|
||||
},
|
||||
'status-pending': {
|
||||
id: 'status-pending',
|
||||
title: 'Waiting for your review',
|
||||
body: "Pending means your child marked this done and is waiting for you to give it a thumbs-up. Tap to approve or reject.",
|
||||
placement: 'auto',
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user