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

- 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:
2026-05-26 16:54:45 -04:00
parent ec4912aa4a
commit d147bd6f27
23 changed files with 1338 additions and 5 deletions

View File

@@ -49,6 +49,8 @@ def get_profile():
'image_id': user.image_id, 'image_id': user.image_id,
'email_digest_enabled': user.email_digest_enabled, 'email_digest_enabled': user.email_digest_enabled,
'push_notifications_enabled': user.push_notifications_enabled, 'push_notifications_enabled': user.push_notifications_enabled,
'tutorial_enabled': user.tutorial_enabled,
'tutorial_progress': user.tutorial_progress or {},
}), 200 }), 200
@user_api.route('/user/profile', methods=['PUT']) @user_api.route('/user/profile', methods=['PUT'])
@@ -109,6 +111,37 @@ def update_profile():
return jsonify({'message': 'Profile updated'}), 200 return jsonify({'message': 'Profile updated'}), 200
@user_api.route('/user/tutorial-progress', methods=['PATCH'])
def update_tutorial_progress():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
user = get_current_user()
if not user:
return jsonify({'error': 'Unauthorized'}), 401
data = request.get_json() or {}
if data.get('reset') is True:
user.tutorial_progress = {}
elif 'enabled' in data:
user.tutorial_enabled = bool(data.get('enabled'))
elif 'step_id' in data:
step_id = str(data.get('step_id') or '').strip()
if not step_id:
return jsonify({'error': 'Missing step_id'}), 400
progress = dict(user.tutorial_progress or {})
progress[step_id] = bool(data.get('seen', True))
user.tutorial_progress = progress
else:
return jsonify({'error': 'No-op'}), 400
users_db.update(user.to_dict(), UserQuery.email == user.email)
send_event_for_current_user(Event(EventType.PROFILE_UPDATED.value, ProfileUpdated(user.id)))
return jsonify({
'tutorial_enabled': user.tutorial_enabled,
'tutorial_progress': user.tutorial_progress,
}), 200
@user_api.route('/user/image', methods=['PUT']) @user_api.route('/user/image', methods=['PUT'])
def update_image(): def update_image():
user_id = get_validated_user_id() user_id = get_validated_user_id()

View File

@@ -25,6 +25,8 @@ class User(BaseModel):
timezone: str | None = None timezone: str | None = None
email_digest_enabled: bool = True email_digest_enabled: bool = True
push_notifications_enabled: bool = True push_notifications_enabled: bool = True
tutorial_enabled: bool = True
tutorial_progress: dict = field(default_factory=dict)
@classmethod @classmethod
def from_dict(cls, d: dict): def from_dict(cls, d: dict):
@@ -51,6 +53,8 @@ class User(BaseModel):
timezone=d.get('timezone'), timezone=d.get('timezone'),
email_digest_enabled=d.get('email_digest_enabled', True), email_digest_enabled=d.get('email_digest_enabled', True),
push_notifications_enabled=d.get('push_notifications_enabled', True), push_notifications_enabled=d.get('push_notifications_enabled', True),
tutorial_enabled=d.get('tutorial_enabled', True),
tutorial_progress=d.get('tutorial_progress', {}) or {},
id=d.get('id'), id=d.get('id'),
created_at=d.get('created_at'), created_at=d.get('created_at'),
updated_at=d.get('updated_at') updated_at=d.get('updated_at')
@@ -82,5 +86,7 @@ class User(BaseModel):
'timezone': self.timezone, 'timezone': self.timezone,
'email_digest_enabled': self.email_digest_enabled, 'email_digest_enabled': self.email_digest_enabled,
'push_notifications_enabled': self.push_notifications_enabled, 'push_notifications_enabled': self.push_notifications_enabled,
'tutorial_enabled': self.tutorial_enabled,
'tutorial_progress': self.tutorial_progress,
}) })
return base return base

View File

@@ -1,10 +1,14 @@
<template> <template>
<BackendEventsListener /> <BackendEventsListener />
<router-view /> <router-view />
<TutorialOverlay />
<TutorialChildModeOffer />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import BackendEventsListener from '@/components/BackendEventsListener.vue' import BackendEventsListener from '@/components/BackendEventsListener.vue'
import TutorialOverlay from '@/tutorial/TutorialOverlay.vue'
import TutorialChildModeOffer from '@/tutorial/TutorialChildModeOffer.vue'
import { checkAuth } from '@/stores/auth' import { checkAuth } from '@/stores/auth'
checkAuth() checkAuth()

View File

@@ -116,6 +116,8 @@ export interface User {
role: string role: string
timezone: string | null timezone: string | null
email_digest_enabled: boolean email_digest_enabled: boolean
tutorial_enabled?: boolean
tutorial_progress?: Record<string, boolean>
} }
export interface Child { export interface Child {

View File

@@ -27,6 +27,7 @@ import {
triggerRoutineAsParent, triggerRoutineAsParent,
} from '@/common/api' } from '@/common/api'
import { eventBus } from '@/common/eventBus' import { eventBus } from '@/common/eventBus'
import { maybeShow as tutorialMaybeShow } from '@/tutorial/controller'
import '@/assets/styles.css' import '@/assets/styles.css'
import type { import type {
Task, Task,
@@ -687,6 +688,36 @@ function openChoreMenu(taskId: string, e: MouseEvent) {
menuPosition.value = { top: rect.bottom, left: rect.right - 140 } menuPosition.value = { top: rect.bottom, left: rect.right - 140 }
} }
activeMenuFor.value = taskId 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() { function closeChoreMenu() {
@@ -1002,6 +1033,13 @@ onMounted(async () => {
child.value = data child.value = data
tasks.value = data.tasks || [] tasks.value = data.tasks || []
rewards.value = data.rewards || [] 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 loading.value = false
if (scrollToId) { 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(() => { onUnmounted(() => {
eventBus.off('child_task_triggered', handleTaskTriggered) eventBus.off('child_task_triggered', handleTaskTriggered)
eventBus.off('child_reward_triggered', handleRewardTriggered) eventBus.off('child_reward_triggered', handleRewardTriggered)

View File

@@ -35,10 +35,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import ItemList from '../shared/ItemList.vue' import ItemList from '../shared/ItemList.vue'
import MessageBlock from '../shared/MessageBlock.vue' import MessageBlock from '../shared/MessageBlock.vue'
import { maybeShow as tutorialMaybeShow, tutorialReady } from '@/tutorial/controller'
import type { import type {
PendingConfirmation, PendingConfirmation,
Event, 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(() => { onMounted(() => {
eventBus.on('child_reward_request', handleRewardRequest) eventBus.on('child_reward_request', handleRewardRequest)
eventBus.on('child_chore_confirmation', handleChoreConfirmation) eventBus.on('child_chore_confirmation', handleChoreConfirmation)

View File

@@ -95,6 +95,54 @@
<button class="btn btn-primary" @click="closeDeleteError">Close</button> <button class="btn btn-primary" @click="closeDeleteError">Close</button>
</div> </div>
</ModalDialog> </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> </div>
</template> </template>
@@ -113,6 +161,11 @@ import {
import { parseErrorResponse, isEmailValid } from '@/common/api' import { parseErrorResponse, isEmailValid } from '@/common/api'
import { ALREADY_MARKED } from '@/common/errorCodes' import { ALREADY_MARKED } from '@/common/errorCodes'
import { logoutUser, suppressForceLogout } from '@/stores/auth' import { logoutUser, suppressForceLogout } from '@/stores/auth'
import {
tutorialEnabled,
setTutorialEnabled,
resetAllProgress,
} from '@/tutorial/controller'
import '@/assets/styles.css' import '@/assets/styles.css'
const router = useRouter() const router = useRouter()
@@ -136,6 +189,25 @@ const deleteErrorMessage = ref('')
const pushError = ref('') const pushError = ref('')
const pushPermissionDenied = ref(false) 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<{ const initialData = ref<{
image_id: string | null image_id: string | null
first_name: string first_name: string
@@ -500,4 +572,45 @@ function closeDeleteError() {
outline: none; outline: none;
border-color: var(--btn-primary, #4a90e2); 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> </style>

View File

@@ -36,7 +36,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import ItemList from '../shared/ItemList.vue' import ItemList from '../shared/ItemList.vue'
import MessageBlock from '@/components/shared/MessageBlock.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 { REWARD_FIELDS } from '@/common/models'
import { eventBus } from '@/common/eventBus' import { eventBus } from '@/common/eventBus'
import {
maybeShow as tutorialMaybeShow,
markStepSeen as tutorialMark,
tutorialReady,
} from '@/tutorial/controller'
const $router = useRouter() 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(() => { onMounted(() => {
eventBus.on('reward_modified', handleRewardModified) eventBus.on('reward_modified', handleRewardModified)
}) })

View File

@@ -122,6 +122,7 @@ import EntityEditForm from '@/components/shared/EntityEditForm.vue'
import ImagePicker from '@/components/utils/ImagePicker.vue' import ImagePicker from '@/components/utils/ImagePicker.vue'
import { getCachedImageUrl } from '@/common/imageCache' import { getCachedImageUrl } from '@/common/imageCache'
import type { RoutineItem } from '@/common/models' import type { RoutineItem } from '@/common/models'
import { maybeShow as tutorialMaybeShow } from '@/tutorial/controller'
import '@/assets/styles.css' import '@/assets/styles.css'
const props = defineProps<{ id?: string }>() const props = defineProps<{ id?: string }>()
@@ -161,6 +162,13 @@ const draggingIdx = ref<number | null>(null)
const dragOverIdx = ref<number | null>(null) const dragOverIdx = ref<number | null>(null)
onMounted(async () => { 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) { if (isEdit.value && props.id) {
loading.value = true loading.value = true
try { try {

View File

@@ -1,9 +1,14 @@
<script setup lang="ts"> <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 { useRouter } from 'vue-router'
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache' import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
import { isParentAuthenticated } from '../../stores/auth' import { isParentAuthenticated } from '../../stores/auth'
import { eventBus } from '@/common/eventBus' import { eventBus } from '@/common/eventBus'
import {
maybeShow as tutorialMaybeShow,
markStepSeen as tutorialMark,
tutorialReady,
} from '@/tutorial/controller'
import type { import type {
Child, Child,
ChildModifiedEventPayload, ChildModifiedEventPayload,
@@ -150,6 +155,22 @@ const createChild = () => {
router.push({ name: '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 () => { onMounted(async () => {
eventBus.on('child_modified', handleChildModified) eventBus.on('child_modified', handleChildModified)
eventBus.on('child_task_triggered', handleChildTaskTriggered) eventBus.on('child_task_triggered', handleChildTaskTriggered)
@@ -158,6 +179,7 @@ onMounted(async () => {
const listPromise = fetchChildren() const listPromise = fetchChildren()
listPromise.then((list) => { listPromise.then((list) => {
children.value = list children.value = list
maybeTriggerCreateChildTutorial()
}) })
// listen for outside clicks to auto-close any open kebab menu // listen for outside clicks to auto-close any open kebab menu
document.addEventListener('click', onDocClick, true) document.addEventListener('click', onDocClick, true)
@@ -214,6 +236,10 @@ const selectChild = (childId: string | number) => {
const openMenu = (childId: string | number, evt?: Event) => { const openMenu = (childId: string | number, evt?: Event) => {
evt?.stopPropagation() evt?.stopPropagation()
activeMenuFor.value = childId activeMenuFor.value = childId
tutorialMaybeShow(
'child-kebab',
() => document.querySelector('.kebab-menu') as HTMLElement | null,
)
} }
const closeMenu = () => { const closeMenu = () => {
activeMenuFor.value = null activeMenuFor.value = null

View File

@@ -21,6 +21,10 @@ import {
isPushOptedOut, isPushOptedOut,
ensurePushSubscriptionSynced, ensurePushSubscriptionSynced,
} from '@/services/pushSubscription' } from '@/services/pushSubscription'
import {
hydrateFromProfile as hydrateTutorial,
maybeShow as tutorialMaybeShow,
} from '@/tutorial/controller'
const router = useRouter() const router = useRouter()
const show = ref(false) const show = ref(false)
@@ -57,6 +61,11 @@ async function fetchUserProfile() {
userImageId.value = data.image_id || null userImageId.value = data.image_id || null
userFirstName.value = data.first_name || '' userFirstName.value = data.first_name || ''
userEmail.value = data.email || '' userEmail.value = data.email || ''
hydrateTutorial({
tutorial_enabled: data.tutorial_enabled,
tutorial_progress: data.tutorial_progress,
})
void maybeShowSetupPinTutorial()
// Update avatar initial // Update avatar initial
avatarInitial.value = userFirstName.value ? userFirstName.value.charAt(0).toUpperCase() : '?' 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) { async function loadAvatarImages(imageId: string) {
try { try {
const blob = await getCachedImageBlob(imageId) const blob = await getCachedImageBlob(imageId)

View File

@@ -161,10 +161,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed, onMounted } from 'vue'
import ModalDialog from './ModalDialog.vue' import ModalDialog from './ModalDialog.vue'
import TimePickerPopover from './TimePickerPopover.vue' import TimePickerPopover from './TimePickerPopover.vue'
import DateInputField from './DateInputField.vue' import DateInputField from './DateInputField.vue'
import { maybeShow as tutorialMaybeShow } from '@/tutorial/controller'
import { import {
setChoreSchedule, setChoreSchedule,
deleteChoreSchedule, deleteChoreSchedule,
@@ -258,6 +259,13 @@ const intervalTime = ref<TimeValue>({
const saving = ref(false) const saving = ref(false)
const errorMsg = ref<string | null>(null) const errorMsg = ref<string | null>(null)
onMounted(() => {
tutorialMaybeShow(
'create-chore-schedule',
() => document.querySelector('.day-chips .chip') as HTMLElement | null,
)
})
// ── original snapshot (for dirty detection) ────────────────────────────────── // ── original snapshot (for dirty detection) ──────────────────────────────────
const origMode = props.schedule?.mode ?? 'days' const origMode = props.schedule?.mode ?? 'days'

View File

@@ -36,7 +36,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import ItemList from '../shared/ItemList.vue' import ItemList from '../shared/ItemList.vue'
import MessageBlock from '@/components/shared/MessageBlock.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 type { Task } from '@/common/models'
import { TASK_FIELDS } from '@/common/models' import { TASK_FIELDS } from '@/common/models'
import { eventBus } from '@/common/eventBus' import { eventBus } from '@/common/eventBus'
import {
maybeShow as tutorialMaybeShow,
markStepSeen as tutorialMark,
tutorialReady,
} from '@/tutorial/controller'
const $router = useRouter() const $router = useRouter()
const showConfirm = ref(false) const showConfirm = ref(false)
@@ -56,6 +61,15 @@ function handleModified() {
listRef.value?.refresh() 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(() => { onMounted(() => {
eventBus.on('task_modified', handleModified) eventBus.on('task_modified', handleModified)
}) })

View File

@@ -18,6 +18,7 @@
import { ref, onMounted, computed, nextTick } from 'vue' import { ref, onMounted, computed, nextTick } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import EntityEditForm from '../shared/EntityEditForm.vue' import EntityEditForm from '../shared/EntityEditForm.vue'
import { maybeShow as tutorialMaybeShow } from '@/tutorial/controller'
import '@/assets/styles.css' import '@/assets/styles.css'
const props = defineProps<{ id?: string }>() const props = defineProps<{ id?: string }>()
@@ -36,6 +37,7 @@ const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
onMounted(async () => { onMounted(async () => {
if (!isEdit.value) tutorialMaybeShow('create-kindness')
if (isEdit.value && props.id) { if (isEdit.value && props.id) {
loading.value = true loading.value = true
try { try {

View File

@@ -18,6 +18,7 @@
import { ref, onMounted, computed, nextTick } from 'vue' import { ref, onMounted, computed, nextTick } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import EntityEditForm from '../shared/EntityEditForm.vue' import EntityEditForm from '../shared/EntityEditForm.vue'
import { maybeShow as tutorialMaybeShow } from '@/tutorial/controller'
import '@/assets/styles.css' import '@/assets/styles.css'
const props = defineProps<{ id?: string }>() const props = defineProps<{ id?: string }>()
@@ -36,6 +37,7 @@ const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
onMounted(async () => { onMounted(async () => {
if (!isEdit.value) tutorialMaybeShow('create-penalty')
if (isEdit.value && props.id) { if (isEdit.value && props.id) {
loading.value = true loading.value = true
try { try {

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick, computed } from 'vue' import { ref, onMounted, onBeforeUnmount, nextTick, computed } from 'vue'
import { getCachedImageUrl } from '@/common/imageCache' import { getCachedImageUrl } from '@/common/imageCache'
import { maybeShow as tutorialMaybeShow } from '@/tutorial/controller'
import '@/assets/styles.css' import '@/assets/styles.css'
const props = defineProps<{ const props = defineProps<{
@@ -159,6 +160,10 @@ onMounted(async () => {
} finally { } finally {
loadingImages.value = false loadingImages.value = false
} }
tutorialMaybeShow(
'create-chore-image',
() => document.querySelector('.icon-btn') as HTMLElement | null,
)
}) })
async function resizeImageFile( async function resizeImageFile(

View File

@@ -5,6 +5,9 @@
<button v-show="showBack" class="back-btn" @click="handleBack" tabindex="0"> Back</button> <button v-show="showBack" class="back-btn" @click="handleBack" tabindex="0"> Back</button>
</div> </div>
<div class="spacer"></div> <div class="spacer"></div>
<div class="end-button-container help-button-container">
<HelpButton />
</div>
<div class="end-button-container"> <div class="end-button-container">
<LoginButton /> <LoginButton />
</div> </div>
@@ -19,6 +22,7 @@
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { computed } from 'vue' import { computed } from 'vue'
import LoginButton from '../components/shared/LoginButton.vue' import LoginButton from '../components/shared/LoginButton.vue'
import HelpButton from '@/tutorial/HelpButton.vue'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -69,6 +73,14 @@ const showBack = computed(() => route.path !== '/child')
box-sizing: border-box; box-sizing: border-box;
} }
.help-button-container {
width: 44px;
min-width: 44px;
max-width: 44px;
margin-left: 0;
margin-right: 0;
}
.back-btn { .back-btn {
width: 65px; width: 65px;
min-width: 65px; min-width: 65px;

View File

@@ -2,6 +2,7 @@
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { computed, ref, onMounted, onUnmounted } from 'vue' import { computed, ref, onMounted, onUnmounted } from 'vue'
import LoginButton from '../components/shared/LoginButton.vue' import LoginButton from '../components/shared/LoginButton.vue'
import HelpButton from '@/tutorial/HelpButton.vue'
import { eventBus } from '@/common/eventBus' import { eventBus } from '@/common/eventBus'
import type { import type {
Event, Event,
@@ -174,6 +175,9 @@ onUnmounted(() => {
</button> </button>
</nav> </nav>
<div v-else class="spacer"></div> <div v-else class="spacer"></div>
<div class="end-button-container help-button-container">
<HelpButton />
</div>
<div class="end-button-container"> <div class="end-button-container">
<LoginButton /> <LoginButton />
</div> </div>
@@ -222,6 +226,14 @@ onUnmounted(() => {
box-sizing: border-box; box-sizing: border-box;
} }
.help-button-container {
width: 44px;
min-width: 44px;
max-width: 44px;
margin-left: 0;
margin-right: 0;
}
.back-btn { .back-btn {
width: 65px; width: 65px;
min-width: 65px; min-width: 65px;

View 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>

View 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>

View 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>

View 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()
})

View 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 46 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 childs 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 chores 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',
},
}