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:
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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