feat: add onboarding tutorial for new users
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 3m4s
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 3m4s
- Introduced a modular tutorial layer to guide new parents through the app setup process. - Implemented a 3-step forced intro after first sign-in (PIN setup → child creation → chore creation). - Added just-in-time contextual hints for various features as users encounter them. - Persisted user progress on the backend with new fields in the User model. - Created a new tutorial controller and step registry in the frontend for managing tutorial states. - Added Help button for easy access to tutorial tips and a restart option in the user profile. - Ensured accessibility and mobile responsiveness for the tutorial overlay. - Included tests for backend and frontend functionalities related to the tutorial.
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import EntityEditForm from '../shared/EntityEditForm.vue'
|
||||
import { maybeShow as tutorialMaybeShow } from '@/tutorial/controller'
|
||||
import '@/assets/styles.css'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -55,6 +56,7 @@ const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!isEdit.value) tutorialMaybeShow('edit-child-name')
|
||||
if (isEdit.value && props.id) {
|
||||
loading.value = true
|
||||
try {
|
||||
|
||||
@@ -27,7 +27,11 @@ import {
|
||||
triggerRoutineAsParent,
|
||||
} from '@/common/api'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import { maybeShow as tutorialMaybeShow } from '@/tutorial/controller'
|
||||
import {
|
||||
maybeShow as tutorialMaybeShow,
|
||||
activeStep as tutorialActiveStep,
|
||||
modalTutorialStepId,
|
||||
} from '@/tutorial/controller'
|
||||
import '@/assets/styles.css'
|
||||
import type {
|
||||
Task,
|
||||
@@ -109,6 +113,9 @@ const selectedChoreId = ref<string | null>(null)
|
||||
const menuPosition = ref({ top: 0, left: 0 })
|
||||
const kebabBtnRefs = ref<Map<string, HTMLElement>>(new Map())
|
||||
|
||||
// Tutorial auto-demo state
|
||||
const tutorialHighlightedItemId = ref<string | null>(null)
|
||||
|
||||
// Schedule modal
|
||||
const showScheduleModal = ref(false)
|
||||
const scheduleTarget = ref<ChildTask | null>(null)
|
||||
@@ -427,10 +434,45 @@ function openRoutineMenu(routineId: string, e: MouseEvent) {
|
||||
e.stopPropagation()
|
||||
const btn = routineKebabBtnRefs.value.get(routineId)
|
||||
if (btn) {
|
||||
btn.scrollIntoView({ block: 'center', behavior: 'auto' })
|
||||
const rect = btn.getBoundingClientRect()
|
||||
menuPosition.value = { top: rect.bottom, left: rect.right - 140 }
|
||||
}
|
||||
activeRoutineMenuFor.value = routineId
|
||||
nextTick(() => {
|
||||
tutorialMaybeShow(
|
||||
'routine-kebab-menu',
|
||||
() => document.querySelector('.kebab-menu') as HTMLElement | null,
|
||||
)
|
||||
tutorialMaybeShow(
|
||||
'kebab-edit-points-cost',
|
||||
() => document.querySelector('[data-tutorial~="kebab-edit-points-cost"]') as HTMLElement | null,
|
||||
)
|
||||
tutorialMaybeShow(
|
||||
'routine-schedule',
|
||||
() => document.querySelector('[data-tutorial="routine-schedule"]') as HTMLElement | null,
|
||||
)
|
||||
})
|
||||
const items = childRoutineListRef.value?.items ?? []
|
||||
const routine = items.find((r) => r.id === routineId)
|
||||
if (routine) {
|
||||
if (isRoutineExpired(routine)) {
|
||||
nextTick(() => {
|
||||
tutorialMaybeShow(
|
||||
'routine-extend-time',
|
||||
() => document.querySelector('[data-tutorial="routine-extend-time"]') as HTMLElement | null,
|
||||
)
|
||||
})
|
||||
}
|
||||
if (isRoutineApprovedToday(routine)) {
|
||||
nextTick(() => {
|
||||
tutorialMaybeShow(
|
||||
'routine-reset',
|
||||
() => document.querySelector('[data-tutorial="routine-reset"]') as HTMLElement | null,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeRoutineMenu() {
|
||||
@@ -666,7 +708,10 @@ const onDocClick = (e: MouseEvent) => {
|
||||
node.classList.contains('kebab-menu')
|
||||
)
|
||||
})
|
||||
if (!inside) {
|
||||
const fromTutorial = path.some(
|
||||
(n) => n instanceof HTMLElement && n.classList.contains('tutorial-root'),
|
||||
)
|
||||
if (!inside && !fromTutorial) {
|
||||
activeMenuFor.value = null
|
||||
activeRoutineMenuFor.value = null
|
||||
selectedChoreId.value = null
|
||||
@@ -684,14 +729,25 @@ function openChoreMenu(taskId: string, e: MouseEvent) {
|
||||
e.stopPropagation()
|
||||
const btn = kebabBtnRefs.value.get(taskId)
|
||||
if (btn) {
|
||||
btn.scrollIntoView({ block: 'center', behavior: 'auto' })
|
||||
const rect = btn.getBoundingClientRect()
|
||||
menuPosition.value = { top: rect.bottom, left: rect.right - 140 }
|
||||
}
|
||||
activeMenuFor.value = taskId
|
||||
tutorialMaybeShow(
|
||||
'chore-kebab',
|
||||
() => document.querySelector('.kebab-menu') as HTMLElement | null,
|
||||
)
|
||||
nextTick(() => {
|
||||
tutorialMaybeShow(
|
||||
'chore-kebab-menu',
|
||||
() => document.querySelector('.kebab-menu') as HTMLElement | null,
|
||||
)
|
||||
tutorialMaybeShow(
|
||||
'kebab-edit-points-cost',
|
||||
() => document.querySelector('[data-tutorial~="kebab-edit-points-cost"]') as HTMLElement | null,
|
||||
)
|
||||
tutorialMaybeShow(
|
||||
'chore-schedule',
|
||||
() => document.querySelector('[data-tutorial="chore-schedule"]') as HTMLElement | null,
|
||||
)
|
||||
})
|
||||
const items: ChildTask[] = childChoreListRef.value?.items ?? []
|
||||
const task = items.find((t) => t.id === taskId)
|
||||
if (task) {
|
||||
@@ -699,10 +755,7 @@ function openChoreMenu(taskId: string, e: MouseEvent) {
|
||||
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,
|
||||
() => document.querySelector('[data-tutorial="chore-extend-time"]') as HTMLElement | null,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -710,10 +763,7 @@ function openChoreMenu(taskId: string, e: MouseEvent) {
|
||||
nextTick(() => {
|
||||
tutorialMaybeShow(
|
||||
'chore-kebab-reset',
|
||||
() =>
|
||||
Array.from(document.querySelectorAll('.kebab-menu .menu-item')).find((el) =>
|
||||
/^reset/i.test((el.textContent || '').trim()),
|
||||
) as HTMLElement | null,
|
||||
() => document.querySelector('[data-tutorial="chore-reset"]') as HTMLElement | null,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -890,6 +940,13 @@ watch(showOverrideModal, async (newVal) => {
|
||||
if (newVal) {
|
||||
await nextTick()
|
||||
document.getElementById('custom-value')?.focus()
|
||||
modalTutorialStepId.value = 'point-editor-help'
|
||||
tutorialMaybeShow(
|
||||
'point-editor-help',
|
||||
() => document.querySelector('input#custom-value') as HTMLElement | null,
|
||||
)
|
||||
} else {
|
||||
modalTutorialStepId.value = null
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1034,11 +1091,9 @@ onMounted(async () => {
|
||||
tasks.value = data.tasks || []
|
||||
rewards.value = data.rewards || []
|
||||
// Fire the per-child overview tour (chains into assign-* steps).
|
||||
// No anchor: this is a general overview so the card stays centered.
|
||||
nextTick(() => {
|
||||
tutorialMaybeShow(
|
||||
'select-child',
|
||||
() => document.querySelector('.assign-buttons') as HTMLElement | null,
|
||||
)
|
||||
tutorialMaybeShow('select-child')
|
||||
})
|
||||
}
|
||||
loading.value = false
|
||||
@@ -1096,6 +1151,66 @@ watch(
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// When the generic kebab-overview step fires, scroll to an assigned item
|
||||
// and select it so its kebab button is visible for the user to discover.
|
||||
watch(
|
||||
() => tutorialActiveStep.value?.def.id,
|
||||
async (stepId, prevStepId) => {
|
||||
if (stepId === 'item-kebab-overview') {
|
||||
const chores: ChildTask[] = childChoreListRef.value?.items ?? []
|
||||
const routines: ChildRoutine[] = childRoutineListRef.value?.items ?? []
|
||||
if (chores.length > 0 && childChoreListRef.value) {
|
||||
const first = chores[0]
|
||||
childChoreListRef.value.scrollToItem(first.id)
|
||||
selectedChoreId.value = first.id
|
||||
tutorialHighlightedItemId.value = first.id
|
||||
await nextTick()
|
||||
const btn = kebabBtnRefs.value.get(first.id)
|
||||
if (btn && tutorialActiveStep.value?.def.id === 'item-kebab-overview') {
|
||||
tutorialActiveStep.value.anchor = () => btn
|
||||
}
|
||||
} else if (routines.length > 0 && childRoutineListRef.value) {
|
||||
const first = routines[0]
|
||||
childRoutineListRef.value.scrollToItem(first.id)
|
||||
selectedRoutineId.value = first.id
|
||||
tutorialHighlightedItemId.value = first.id
|
||||
await nextTick()
|
||||
const btn = routineKebabBtnRefs.value.get(first.id)
|
||||
if (btn && tutorialActiveStep.value?.def.id === 'item-kebab-overview') {
|
||||
tutorialActiveStep.value.anchor = () => btn
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up the highlighted selection when the tutorial leaves kebab steps
|
||||
const kebabStepIds = new Set([
|
||||
'item-kebab-overview',
|
||||
'chore-kebab-menu',
|
||||
'chore-edit-points',
|
||||
'chore-schedule',
|
||||
'chore-kebab-extend-time',
|
||||
'chore-kebab-reset',
|
||||
'routine-kebab-menu',
|
||||
'routine-edit',
|
||||
'routine-edit-points',
|
||||
'routine-schedule',
|
||||
'routine-extend-time',
|
||||
'routine-reset',
|
||||
'kebab-edit-points-cost',
|
||||
])
|
||||
if (
|
||||
prevStepId &&
|
||||
kebabStepIds.has(prevStepId) &&
|
||||
!kebabStepIds.has(stepId ?? '') &&
|
||||
tutorialHighlightedItemId.value
|
||||
) {
|
||||
selectedChoreId.value = null
|
||||
selectedRoutineId.value = null
|
||||
tutorialHighlightedItemId.value = null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
eventBus.off('child_task_triggered', handleTaskTriggered)
|
||||
eventBus.off('child_reward_triggered', handleRewardTriggered)
|
||||
@@ -1372,11 +1487,12 @@ function goToAssignRoutines() {
|
||||
@mousedown.stop.prevent
|
||||
@click.stop
|
||||
>
|
||||
<button class="menu-item" @mousedown.stop.prevent @click="editChorePoints(item)">
|
||||
<button class="menu-item" data-tutorial="chore-edit-points kebab-edit-points-cost" @mousedown.stop.prevent @click="editChorePoints(item)">
|
||||
Edit Points
|
||||
</button>
|
||||
<button
|
||||
class="menu-item"
|
||||
data-tutorial="chore-schedule"
|
||||
@mousedown.stop.prevent
|
||||
@click="openScheduleModal(item, $event)"
|
||||
>
|
||||
@@ -1385,6 +1501,7 @@ function goToAssignRoutines() {
|
||||
<button
|
||||
v-if="isChoreExpired(item)"
|
||||
class="menu-item"
|
||||
data-tutorial="chore-extend-time"
|
||||
@mousedown.stop.prevent
|
||||
@click="doExtendTime(item, $event)"
|
||||
>
|
||||
@@ -1393,6 +1510,7 @@ function goToAssignRoutines() {
|
||||
<button
|
||||
v-if="isChoreCompletedToday(item)"
|
||||
class="menu-item"
|
||||
data-tutorial="chore-reset"
|
||||
@mousedown.stop.prevent
|
||||
@click="doResetChore(item, $event)"
|
||||
>
|
||||
@@ -1481,11 +1599,12 @@ function goToAssignRoutines() {
|
||||
@mousedown.stop.prevent
|
||||
@click.stop
|
||||
>
|
||||
<button class="menu-item" @mousedown.stop.prevent @click="editRoutine(item)">
|
||||
<button class="menu-item" data-tutorial="routine-edit" @mousedown.stop.prevent @click="editRoutine(item)">
|
||||
Edit Routine
|
||||
</button>
|
||||
<button
|
||||
class="menu-item"
|
||||
data-tutorial="routine-edit-points kebab-edit-points-cost"
|
||||
@mousedown.stop.prevent
|
||||
@click="editRoutinePoints(item)"
|
||||
>
|
||||
@@ -1493,6 +1612,7 @@ function goToAssignRoutines() {
|
||||
</button>
|
||||
<button
|
||||
class="menu-item"
|
||||
data-tutorial="routine-schedule"
|
||||
@mousedown.stop.prevent
|
||||
@click="openRoutineScheduleModal(item, $event)"
|
||||
>
|
||||
@@ -1501,6 +1621,7 @@ function goToAssignRoutines() {
|
||||
<button
|
||||
v-if="isRoutineExpired(item)"
|
||||
class="menu-item"
|
||||
data-tutorial="routine-extend-time"
|
||||
@mousedown.stop.prevent
|
||||
@click="doExtendRoutineTime(item, $event)"
|
||||
>
|
||||
@@ -1509,6 +1630,7 @@ function goToAssignRoutines() {
|
||||
<button
|
||||
v-if="isRoutineApprovedToday(item)"
|
||||
class="menu-item"
|
||||
data-tutorial="routine-reset"
|
||||
@mousedown.stop.prevent
|
||||
@click="doResetRoutine(item, $event)"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<section class="profile-section">
|
||||
<button
|
||||
type="button"
|
||||
class="section-header"
|
||||
:aria-expanded="isOpen"
|
||||
:aria-controls="contentId"
|
||||
@click="isOpen = !isOpen"
|
||||
>
|
||||
<span class="section-title">{{ title }}</span>
|
||||
<span class="section-chevron" :class="{ open: isOpen }" aria-hidden="true">›</span>
|
||||
</button>
|
||||
<div
|
||||
:id="contentId"
|
||||
ref="contentRef"
|
||||
class="section-body"
|
||||
:class="{ open: isOpen }"
|
||||
:style="bodyStyle"
|
||||
:inert="!isOpen"
|
||||
:aria-hidden="!isOpen"
|
||||
>
|
||||
<div ref="innerRef" class="section-inner">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
defaultOpen?: boolean
|
||||
}>()
|
||||
|
||||
const isOpen = ref(props.defaultOpen ?? false)
|
||||
const contentRef = ref<HTMLDivElement | null>(null)
|
||||
const innerRef = ref<HTMLDivElement | null>(null)
|
||||
const bodyHeight = ref<number | null>(null)
|
||||
|
||||
const contentId = computed(() => `section-${props.title.toLowerCase().replace(/\s+/g, '-')}`)
|
||||
|
||||
const bodyStyle = computed(() => {
|
||||
if (isOpen.value && bodyHeight.value !== null) {
|
||||
return {
|
||||
maxHeight: `${bodyHeight.value}px`,
|
||||
opacity: 1,
|
||||
visibility: 'visible',
|
||||
}
|
||||
}
|
||||
return {
|
||||
maxHeight: '0px',
|
||||
opacity: 0,
|
||||
visibility: 'hidden',
|
||||
}
|
||||
})
|
||||
|
||||
async function measureHeight() {
|
||||
await nextTick()
|
||||
if (contentRef.value) {
|
||||
bodyHeight.value = contentRef.value.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (open) {
|
||||
measureHeight()
|
||||
}
|
||||
})
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
if (isOpen.value) {
|
||||
measureHeight()
|
||||
}
|
||||
window.addEventListener('resize', measureHeight)
|
||||
|
||||
if (innerRef.value && 'ResizeObserver' in window) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (isOpen.value) {
|
||||
measureHeight()
|
||||
}
|
||||
})
|
||||
resizeObserver.observe(innerRef.value)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', measureHeight)
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.profile-section {
|
||||
border-bottom: 1px solid var(--form-input-border, #e6e6e6);
|
||||
}
|
||||
|
||||
.profile-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 1rem 0;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--form-heading, #667eea);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.section-header:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.section-header:focus-visible {
|
||||
outline: 2px solid var(--btn-primary, #667eea);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.section-chevron {
|
||||
display: inline-block;
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-secondary, #888);
|
||||
transition: transform 0.25s ease;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.section-chevron.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.section-body {
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease, opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.section-inner {
|
||||
padding-bottom: 1.2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,40 +1,97 @@
|
||||
<template>
|
||||
<div class="view">
|
||||
<EntityEditForm
|
||||
entityLabel="User Profile"
|
||||
:fields="fields"
|
||||
:initialData="initialData"
|
||||
:isEdit="true"
|
||||
:loading="loading"
|
||||
:error="errorMsg"
|
||||
:title="'User Profile'"
|
||||
:fieldErrors="{ push_enabled: pushError }"
|
||||
@submit="handleSubmit"
|
||||
@cancel="router.back"
|
||||
@add-image="onAddImage"
|
||||
>
|
||||
<template #custom-field-email="{ modelValue }">
|
||||
<div class="email-actions">
|
||||
<input id="email" type="email" :value="modelValue" disabled class="readonly-input" />
|
||||
<h2>Profile</h2>
|
||||
|
||||
<div v-if="loading" class="loading-message">Loading profile...</div>
|
||||
<div v-else class="profile-card">
|
||||
<div v-if="errorMsg" class="error-banner" aria-live="polite">{{ errorMsg }}</div>
|
||||
|
||||
<ProfileSection title="User" :defaultOpen="true">
|
||||
<div class="name-fields" @focusout="handleNameFocusOut">
|
||||
<div class="field-group">
|
||||
<label for="first-name">First Name</label>
|
||||
<input
|
||||
id="first-name"
|
||||
v-model="firstName"
|
||||
type="text"
|
||||
maxlength="64"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label for="last-name">Last Name</label>
|
||||
<input
|
||||
id="last-name"
|
||||
v-model="lastName"
|
||||
type="text"
|
||||
maxlength="64"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label>Image</label>
|
||||
<ImagePicker
|
||||
:modelValue="imageId"
|
||||
@update:modelValue="onImageChange"
|
||||
@add-image="onAddImage"
|
||||
:image-type="1"
|
||||
/>
|
||||
</div>
|
||||
</ProfileSection>
|
||||
|
||||
<ProfileSection title="Account">
|
||||
<div class="field-group">
|
||||
<label for="email">Email Address</label>
|
||||
<input id="email" type="email" :value="email" disabled class="readonly-input" />
|
||||
</div>
|
||||
<div class="action-links">
|
||||
<button type="button" class="btn-link btn-link-space" @click="goToChangeParentPin">
|
||||
Change Parent PIN
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link btn-link-space"
|
||||
@click="resetPassword"
|
||||
:disabled="resetting"
|
||||
>
|
||||
<button type="button" class="btn-link btn-link-space" @click="resetPassword" :disabled="resetting">
|
||||
Change Password
|
||||
</button>
|
||||
<button type="button" class="btn-link btn-link-space" @click="openDeleteWarning">
|
||||
Delete My Account
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</EntityEditForm>
|
||||
</ProfileSection>
|
||||
|
||||
<div v-if="errorMsg" class="error-message" aria-live="polite">{{ errorMsg }}</div>
|
||||
<ProfileSection title="Notifications">
|
||||
<div class="toggle-stack">
|
||||
<ToggleField
|
||||
label="Daily Digest"
|
||||
:modelValue="emailDigestEnabled"
|
||||
@update:modelValue="onToggleDigest"
|
||||
:disabled="saving"
|
||||
description="Receive a 9pm summary of pending chore and reward requests with one-click approve/deny links."
|
||||
/>
|
||||
<ToggleField
|
||||
label="Push Notifications"
|
||||
:modelValue="pushEnabled"
|
||||
@update:modelValue="onTogglePush"
|
||||
:disabled="saving || pushPermissionDenied"
|
||||
description="Receive instant push notifications when a chore or reward needs your approval."
|
||||
:error="pushError"
|
||||
/>
|
||||
</div>
|
||||
</ProfileSection>
|
||||
|
||||
<ProfileSection title="Help">
|
||||
<ToggleField
|
||||
label="Show tutorial tips"
|
||||
:modelValue="tutorialEnabled"
|
||||
@update:modelValue="onToggleTutorial"
|
||||
description="Show helpful tips as I use the app."
|
||||
/>
|
||||
<button type="button" class="btn-link btn-link-space" @click="openRestartConfirm">
|
||||
Restart tutorial
|
||||
</button>
|
||||
</ProfileSection>
|
||||
</div>
|
||||
|
||||
<!-- Password reset modal -->
|
||||
<ModalDialog
|
||||
v-if="showModal"
|
||||
:title="modalTitle"
|
||||
@@ -96,27 +153,6 @@
|
||||
</div>
|
||||
</ModalDialog>
|
||||
|
||||
<!-- Help / Tutorial section -->
|
||||
<section class="help-section" aria-labelledby="help-heading">
|
||||
<h3 id="help-heading" class="help-heading">Help</h3>
|
||||
<label class="help-row">
|
||||
<span class="help-row-text">
|
||||
<span class="help-row-label">Show tutorial tips</span>
|
||||
<span class="help-row-desc">Show helpful tips as I use the app.</span>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="help-toggle"
|
||||
:checked="tutorialEnabled"
|
||||
@change="onToggleTutorial"
|
||||
aria-label="Show tutorial tips"
|
||||
/>
|
||||
</label>
|
||||
<button type="button" class="btn-link btn-link-space" @click="openRestartConfirm">
|
||||
Restart tutorial
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- Restart confirmation -->
|
||||
<ModalDialog
|
||||
v-if="showRestartConfirm"
|
||||
@@ -133,11 +169,7 @@
|
||||
</ModalDialog>
|
||||
|
||||
<!-- Restart success -->
|
||||
<ModalDialog
|
||||
v-if="showRestartSuccess"
|
||||
title="Tour reset"
|
||||
@close="showRestartSuccess = false"
|
||||
>
|
||||
<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>
|
||||
@@ -147,10 +179,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import EntityEditForm from '../shared/EntityEditForm.vue'
|
||||
import ModalDialog from '../shared/ModalDialog.vue'
|
||||
import ProfileSection from './ProfileSection.vue'
|
||||
import ImagePicker from '@/components/utils/ImagePicker.vue'
|
||||
import ToggleField from '@/components/shared/ToggleField.vue'
|
||||
import ModalDialog from '@/components/shared/ModalDialog.vue'
|
||||
import {
|
||||
isSubscribedToPush,
|
||||
subscribeToPushWithResult,
|
||||
@@ -161,24 +195,34 @@ import {
|
||||
import { parseErrorResponse, isEmailValid } from '@/common/api'
|
||||
import { ALREADY_MARKED } from '@/common/errorCodes'
|
||||
import { logoutUser, suppressForceLogout } from '@/stores/auth'
|
||||
import {
|
||||
tutorialEnabled,
|
||||
setTutorialEnabled,
|
||||
resetAllProgress,
|
||||
} from '@/tutorial/controller'
|
||||
import { tutorialEnabled, setTutorialEnabled, resetAllProgress } from '@/tutorial/controller'
|
||||
import '@/assets/styles.css'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
|
||||
// Profile data
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const resetting = ref(false)
|
||||
|
||||
const firstName = ref('')
|
||||
const lastName = ref('')
|
||||
const imageId = ref<string | null>(null)
|
||||
const email = ref('')
|
||||
const emailDigestEnabled = ref(true)
|
||||
const pushEnabled = ref(false)
|
||||
const pushPermissionDenied = ref(false)
|
||||
const pushError = ref('')
|
||||
|
||||
const localImageFile = ref<File | null>(null)
|
||||
|
||||
// Modal state
|
||||
const resetting = ref(false)
|
||||
const showModal = ref(false)
|
||||
const modalTitle = ref('')
|
||||
const modalSubtitle = ref('')
|
||||
const modalMessage = ref('')
|
||||
|
||||
// Delete account modal state
|
||||
const showDeleteWarning = ref(false)
|
||||
const confirmEmail = ref('')
|
||||
const deletingAccount = ref(false)
|
||||
@@ -186,65 +230,10 @@ const showDeleteSuccess = ref(false)
|
||||
const showDeleteError = ref(false)
|
||||
const deleteErrorMessage = ref('')
|
||||
|
||||
const pushError = ref('')
|
||||
const pushPermissionDenied = ref(false)
|
||||
|
||||
// Tutorial controls
|
||||
const showRestartConfirm = ref(false)
|
||||
const showRestartSuccess = ref(false)
|
||||
|
||||
async function onToggleTutorial(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
await setTutorialEnabled(target.checked)
|
||||
}
|
||||
|
||||
function openRestartConfirm() {
|
||||
showRestartConfirm.value = true
|
||||
}
|
||||
|
||||
async function confirmRestartTutorial() {
|
||||
showRestartConfirm.value = false
|
||||
await resetAllProgress()
|
||||
showRestartSuccess.value = true
|
||||
}
|
||||
|
||||
const initialData = ref<{
|
||||
image_id: string | null
|
||||
first_name: string
|
||||
last_name: string
|
||||
email: string
|
||||
email_digest_enabled: boolean
|
||||
push_enabled: boolean
|
||||
}>({
|
||||
image_id: null,
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
email_digest_enabled: true,
|
||||
push_enabled: false,
|
||||
})
|
||||
|
||||
const fields = computed(() => [
|
||||
{ name: 'image_id', label: 'Image', type: 'image' as const, imageType: 1 },
|
||||
{ name: 'first_name', label: 'First Name', type: 'text' as const, required: true, maxlength: 64 },
|
||||
{ name: 'last_name', label: 'Last Name', type: 'text' as const, required: true, maxlength: 64 },
|
||||
{ name: 'email', label: 'Email Address', type: 'custom' as const },
|
||||
{
|
||||
name: 'email_digest_enabled',
|
||||
label: 'Daily Digest',
|
||||
type: 'toggle' as const,
|
||||
description:
|
||||
'Receive a 9pm summary of pending chore and reward requests with one-click approve/deny links.',
|
||||
},
|
||||
{
|
||||
name: 'push_enabled',
|
||||
label: 'Push Notifications',
|
||||
type: 'toggle' as const,
|
||||
description: 'Receive instant push notifications when a chore or reward needs your approval.',
|
||||
disabled: pushPermissionDenied.value,
|
||||
},
|
||||
])
|
||||
|
||||
// Load profile
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -253,14 +242,12 @@ onMounted(async () => {
|
||||
const data = await res.json()
|
||||
pushPermissionDenied.value = getPushPermissionState() === 'denied'
|
||||
const pushSubscribed = await isSubscribedToPush()
|
||||
initialData.value = {
|
||||
image_id: data.image_id || null,
|
||||
first_name: data.first_name || '',
|
||||
last_name: data.last_name || '',
|
||||
email: data.email || '',
|
||||
email_digest_enabled: data.email_digest_enabled !== false,
|
||||
push_enabled: data.push_notifications_enabled !== false && pushSubscribed,
|
||||
}
|
||||
firstName.value = data.first_name || ''
|
||||
lastName.value = data.last_name || ''
|
||||
imageId.value = data.image_id || null
|
||||
email.value = data.email || ''
|
||||
emailDigestEnabled.value = data.email_digest_enabled !== false
|
||||
pushEnabled.value = data.push_notifications_enabled !== false && pushSubscribed
|
||||
} catch {
|
||||
errorMsg.value = 'Could not load user profile.'
|
||||
} finally {
|
||||
@@ -268,102 +255,139 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
function onAddImage({ id, file }: { id: string; file: File }) {
|
||||
if (id === 'local-upload') {
|
||||
localImageFile.value = file
|
||||
} else {
|
||||
localImageFile.value = null
|
||||
initialData.value.image_id = id
|
||||
function handleNameFocusOut(event: FocusEvent) {
|
||||
const wrapper = event.currentTarget as HTMLElement
|
||||
const relatedTarget = event.relatedTarget as HTMLElement | null
|
||||
if (relatedTarget && wrapper.contains(relatedTarget)) {
|
||||
return
|
||||
}
|
||||
saveNames()
|
||||
}
|
||||
|
||||
// ─── Auto-save: names ───
|
||||
async function saveNames() {
|
||||
if (saving.value) return
|
||||
saving.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const res = await fetch('/api/user/profile', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
first_name: firstName.value,
|
||||
last_name: lastName.value,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to update profile')
|
||||
} catch {
|
||||
errorMsg.value = 'Failed to update profile.'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(form: {
|
||||
image_id: string | null
|
||||
first_name: string
|
||||
last_name: string
|
||||
email: string
|
||||
email_digest_enabled?: boolean
|
||||
push_enabled?: boolean
|
||||
}) {
|
||||
errorMsg.value = ''
|
||||
loading.value = true
|
||||
// ─── Auto-save: image ───
|
||||
function onImageChange(id: string | null) {
|
||||
if (id === 'local-upload') {
|
||||
// localImageFile is set by onAddImage which fires first
|
||||
uploadLocalImage()
|
||||
} else {
|
||||
localImageFile.value = null
|
||||
saveImage(id)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle image upload if local file
|
||||
let imageId = form.image_id
|
||||
if (imageId === 'local-upload' && localImageFile.value) {
|
||||
function onAddImage({ id, file }: { id: string; file: File }) {
|
||||
if (id === 'local-upload') {
|
||||
localImageFile.value = file
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadLocalImage() {
|
||||
if (!localImageFile.value) return
|
||||
saving.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', localImageFile.value)
|
||||
formData.append('type', '1')
|
||||
formData.append('permanent', 'true')
|
||||
fetch('/api/image/upload', {
|
||||
const resp = await fetch('/api/image/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
.then(async (resp) => {
|
||||
if (!resp.ok) throw new Error('Image upload failed')
|
||||
const data = await resp.json()
|
||||
imageId = data.id
|
||||
// Now update profile
|
||||
return updateProfile({
|
||||
...form,
|
||||
image_id: imageId,
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
errorMsg.value = 'Failed to upload image.'
|
||||
loading.value = false
|
||||
})
|
||||
} else {
|
||||
updateProfile(form)
|
||||
if (!resp.ok) throw new Error('Image upload failed')
|
||||
const data = await resp.json()
|
||||
imageId.value = data.id
|
||||
await saveImage(data.id)
|
||||
} catch {
|
||||
errorMsg.value = 'Failed to upload image.'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProfile(form: {
|
||||
image_id: string | null
|
||||
first_name: string
|
||||
last_name: string
|
||||
email: string
|
||||
email_digest_enabled?: boolean
|
||||
push_enabled?: boolean
|
||||
}) {
|
||||
const prevDigest = initialData.value.email_digest_enabled
|
||||
const prevPush = initialData.value.push_enabled
|
||||
async function saveImage(id: string | null) {
|
||||
saving.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
first_name: form.first_name,
|
||||
last_name: form.last_name,
|
||||
image_id: form.image_id,
|
||||
}
|
||||
if (form.email_digest_enabled !== undefined && form.email_digest_enabled !== prevDigest) {
|
||||
body.email_digest_enabled = form.email_digest_enabled
|
||||
}
|
||||
if (form.push_enabled !== undefined && form.push_enabled !== prevPush) {
|
||||
body.push_notifications_enabled = form.push_enabled
|
||||
}
|
||||
const res = await fetch('/api/user/profile', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
body: JSON.stringify({ image_id: id }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to update profile')
|
||||
let actualPushEnabled = prevPush
|
||||
if (form.push_enabled !== undefined && form.push_enabled !== prevPush) {
|
||||
const pushOk = await applyPushChange(form.push_enabled)
|
||||
actualPushEnabled = pushOk ? form.push_enabled : prevPush
|
||||
}
|
||||
initialData.value = {
|
||||
...initialData.value,
|
||||
...form,
|
||||
push_enabled: actualPushEnabled,
|
||||
}
|
||||
modalTitle.value = 'Profile Updated'
|
||||
modalSubtitle.value = ''
|
||||
modalMessage.value = 'Your profile was updated successfully.'
|
||||
showModal.value = true
|
||||
} catch {
|
||||
errorMsg.value = 'Failed to update profile.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Auto-save: toggles ───
|
||||
async function onToggleDigest(val: boolean) {
|
||||
emailDigestEnabled.value = val
|
||||
if (saving.value) return
|
||||
saving.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const res = await fetch('/api/user/profile', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email_digest_enabled: val }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to update profile')
|
||||
} catch {
|
||||
errorMsg.value = 'Failed to update profile.'
|
||||
emailDigestEnabled.value = !val
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onTogglePush(val: boolean) {
|
||||
const prevPush = pushEnabled.value
|
||||
pushEnabled.value = val
|
||||
if (saving.value) return
|
||||
saving.value = true
|
||||
errorMsg.value = ''
|
||||
pushError.value = ''
|
||||
try {
|
||||
const res = await fetch('/api/user/profile', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ push_notifications_enabled: val }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to update profile')
|
||||
const pushOk = await applyPushChange(val)
|
||||
if (!pushOk) {
|
||||
pushEnabled.value = prevPush
|
||||
}
|
||||
} catch {
|
||||
errorMsg.value = 'Failed to update profile.'
|
||||
pushEnabled.value = prevPush
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,16 +416,13 @@ async function applyPushChange(newValue: boolean): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasswordModalClose() {
|
||||
const wasProfileUpdate = modalTitle.value === 'Profile Updated'
|
||||
showModal.value = false
|
||||
if (wasProfileUpdate) {
|
||||
router.back()
|
||||
}
|
||||
// ─── Tutorial toggle ───
|
||||
async function onToggleTutorial(val: boolean) {
|
||||
await setTutorialEnabled(val)
|
||||
}
|
||||
|
||||
// ─── Password reset ───
|
||||
async function resetPassword() {
|
||||
// Show modal immediately with loading message
|
||||
modalTitle.value = 'Change Password'
|
||||
modalMessage.value = 'Sending password change email...'
|
||||
modalSubtitle.value = ''
|
||||
@@ -412,7 +433,7 @@ async function resetPassword() {
|
||||
const res = await fetch('/api/auth/request-password-reset', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: initialData.value.email }),
|
||||
body: JSON.stringify({ email: email.value }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to send reset email')
|
||||
modalTitle.value = 'Password Change Email Sent'
|
||||
@@ -426,10 +447,16 @@ async function resetPassword() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasswordModalClose() {
|
||||
showModal.value = false
|
||||
}
|
||||
|
||||
// ─── Navigation ───
|
||||
function goToChangeParentPin() {
|
||||
router.push({ name: 'ParentPinSetup' })
|
||||
}
|
||||
|
||||
// ─── Delete account ───
|
||||
function openDeleteWarning() {
|
||||
confirmEmail.value = ''
|
||||
showDeleteWarning.value = true
|
||||
@@ -442,9 +469,6 @@ function closeDeleteWarning() {
|
||||
|
||||
async function confirmDeleteAccount() {
|
||||
if (!isEmailValid(confirmEmail.value)) return
|
||||
|
||||
// Set flag before the request so it's guaranteed to be set
|
||||
// before the force_logout SSE event can arrive on this tab
|
||||
suppressForceLogout.value = true
|
||||
deletingAccount.value = true
|
||||
try {
|
||||
@@ -453,7 +477,6 @@ async function confirmDeleteAccount() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: confirmEmail.value }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
suppressForceLogout.value = false
|
||||
const { msg, code } = await parseErrorResponse(res)
|
||||
@@ -466,8 +489,6 @@ async function confirmDeleteAccount() {
|
||||
showDeleteError.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// Success — suppressForceLogout is already set; show confirmation modal
|
||||
showDeleteWarning.value = false
|
||||
showDeleteSuccess.value = true
|
||||
} catch {
|
||||
@@ -482,12 +503,10 @@ async function confirmDeleteAccount() {
|
||||
|
||||
function handleDeleteSuccess() {
|
||||
showDeleteSuccess.value = false
|
||||
// Call logout API to clear server cookies
|
||||
fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
}).finally(() => {
|
||||
// Clear client-side auth and redirect, regardless of logout response
|
||||
logoutUser()
|
||||
router.push('/')
|
||||
})
|
||||
@@ -497,65 +516,134 @@ function closeDeleteError() {
|
||||
showDeleteError.value = false
|
||||
deleteErrorMessage.value = ''
|
||||
}
|
||||
|
||||
// ─── Tutorial restart ───
|
||||
function openRestartConfirm() {
|
||||
showRestartConfirm.value = true
|
||||
}
|
||||
|
||||
async function confirmRestartTutorial() {
|
||||
showRestartConfirm.value = false
|
||||
await resetAllProgress()
|
||||
showRestartSuccess.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.view {
|
||||
max-width: 400px;
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
background: var(--form-bg);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 24px var(--form-shadow);
|
||||
padding: 2rem 2.2rem 1.5rem 2.2rem;
|
||||
}
|
||||
/* ...existing styles... */
|
||||
.email-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
color: var(--success, #16a34a);
|
||||
.loading-message {
|
||||
text-align: center;
|
||||
color: var(--loading-color, #888);
|
||||
font-size: 1rem;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
.error-message {
|
||||
|
||||
.error-banner {
|
||||
color: var(--error, #e53e3e);
|
||||
font-size: 0.98rem;
|
||||
margin-top: 0.4rem;
|
||||
font-size: 0.95rem;
|
||||
padding: 0.6rem 0;
|
||||
margin-bottom: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
.readonly-input {
|
||||
|
||||
.field-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.field-group label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
color: var(--form-label, #444);
|
||||
margin-bottom: 0.4rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.field-group input[type='text'],
|
||||
.field-group input[type='email'] {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--form-input-border, #e6e6e6);
|
||||
font-size: 1rem;
|
||||
background: var(--form-input-bg, #f5f5f5);
|
||||
color: var(--form-label, #888);
|
||||
background: var(--form-input-bg, #fff);
|
||||
color: var(--text-primary, #222);
|
||||
box-sizing: border-box;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.btn-danger-link {
|
||||
.field-group input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.readonly-input {
|
||||
background: var(--form-input-bg, #f5f5f5) !important;
|
||||
color: var(--form-label, #888) !important;
|
||||
}
|
||||
|
||||
.action-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.toggle-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--error, #e53e3e);
|
||||
color: var(--btn-primary, #667eea);
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
margin-top: 0.25rem;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.btn-danger-link:hover {
|
||||
color: var(--error-hover, #c53030);
|
||||
.btn-link:hover {
|
||||
color: var(--btn-primary-hover, #5a67d8);
|
||||
}
|
||||
|
||||
.btn-danger-link:disabled {
|
||||
.btn-link:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.modal-message {
|
||||
text-align: center;
|
||||
color: var(--dialog-message, #444);
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin-top: 1.2rem;
|
||||
}
|
||||
|
||||
.email-confirm-input {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
@@ -573,44 +661,17 @@ function closeDeleteError() {
|
||||
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;
|
||||
@media (max-width: 480px) {
|
||||
.profile-card {
|
||||
padding: 0.5rem 1rem 1rem;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.view {
|
||||
max-width: 100%;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import EntityEditForm from '../shared/EntityEditForm.vue'
|
||||
import { maybeShow as tutorialMaybeShow } from '@/tutorial/controller'
|
||||
import '@/assets/styles.css'
|
||||
|
||||
const props = defineProps<{ id?: string }>()
|
||||
@@ -46,6 +47,7 @@ const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!isEdit.value) tutorialMaybeShow('edit-reward-name')
|
||||
if (isEdit.value && props.id) {
|
||||
loading.value = true
|
||||
try {
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ItemList from '../shared/ItemList.vue'
|
||||
import MessageBlock from '@/components/shared/MessageBlock.vue'
|
||||
@@ -47,11 +47,6 @@ import type { Reward } from '@/common/models'
|
||||
import { REWARD_FIELDS } from '@/common/models'
|
||||
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import {
|
||||
maybeShow as tutorialMaybeShow,
|
||||
markStepSeen as tutorialMark,
|
||||
tutorialReady,
|
||||
} from '@/tutorial/controller'
|
||||
|
||||
const $router = useRouter()
|
||||
|
||||
@@ -67,15 +62,6 @@ function handleRewardModified(event: any) {
|
||||
}
|
||||
}
|
||||
|
||||
watch([rewardCountRef, tutorialReady], ([count, ready]) => {
|
||||
if (!ready) return
|
||||
if (count === 0) {
|
||||
tutorialMaybeShow('create-reward', () => document.querySelector('.fab') as HTMLElement | null)
|
||||
} else if (typeof count === 'number' && count > 0) {
|
||||
void tutorialMark('has-created-reward')
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
eventBus.on('reward_modified', handleRewardModified)
|
||||
})
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
:data-idx="idx"
|
||||
:class="{ 'drag-over': dragOverIdx === idx, dragging: draggingIdx === idx }"
|
||||
>
|
||||
<span class="drag-handle" title="Drag to reorder" @pointerdown.prevent="(e) => onPointerDown(e, idx)">⠿</span>
|
||||
<span class="drag-handle" data-tutorial="routine-task-reorder" title="Drag to reorder" @pointerdown.prevent="(e) => onPointerDown(e, idx)">⠿</span>
|
||||
<div class="item-left">
|
||||
<img
|
||||
v-if="item.image_url"
|
||||
@@ -43,11 +43,17 @@
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary small-btn"
|
||||
data-tutorial="routine-task-edit"
|
||||
@click="startEditItem(idx)"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary small-btn" @click="removeItem(idx)">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary small-btn"
|
||||
data-tutorial="routine-task-delete"
|
||||
@click="removeItem(idx)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
@@ -162,13 +168,7 @@ const draggingIdx = ref<number | null>(null)
|
||||
const dragOverIdx = ref<number | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!isEdit.value) {
|
||||
tutorialMaybeShow('create-routine')
|
||||
tutorialMaybeShow(
|
||||
'create-routine-add-task',
|
||||
() => document.querySelector('.add-task-trigger') as HTMLElement | null,
|
||||
)
|
||||
}
|
||||
if (!isEdit.value) tutorialMaybeShow('edit-routine-name')
|
||||
if (isEdit.value && props.id) {
|
||||
loading.value = true
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, onUnmounted, watch } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
|
||||
import { isParentAuthenticated } from '../../stores/auth'
|
||||
@@ -160,9 +160,14 @@ function maybeTriggerCreateChildTutorial() {
|
||||
if (loading.value) return
|
||||
if (!tutorialReady.value) return
|
||||
if (children.value.length === 0) {
|
||||
tutorialMaybeShow('create-child', () => document.querySelector('.fab') as HTMLElement | null)
|
||||
nextTick(() => {
|
||||
tutorialMaybeShow('create-child')
|
||||
})
|
||||
} else {
|
||||
void tutorialMark('has-created-child')
|
||||
nextTick(() => {
|
||||
tutorialMaybeShow('child-points')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,12 @@
|
||||
|
||||
<!-- Selected day exception list -->
|
||||
<div v-if="selectedDays.size > 0" class="exception-list">
|
||||
<div v-for="idx in sortedSelectedDays" :key="idx" class="exception-row">
|
||||
<div
|
||||
v-for="(idx, i) in sortedSelectedDays"
|
||||
:key="idx"
|
||||
class="exception-row"
|
||||
:data-tutorial="i === 0 ? 'schedule-days-exception' : undefined"
|
||||
>
|
||||
<span class="exception-day-name">{{ DAY_LABELS[idx] }}</span>
|
||||
<div class="exception-right">
|
||||
<template v-if="exceptions.has(idx)">
|
||||
@@ -114,7 +119,7 @@
|
||||
</div>
|
||||
<span class="field-label">{{ intervalDays === 1 ? 'day' : 'days' }}</span>
|
||||
</div>
|
||||
<div class="interval-row">
|
||||
<div class="interval-row" data-tutorial="schedule-interval-start">
|
||||
<label class="field-label">Starting on</label>
|
||||
<DateInputField
|
||||
:modelValue="anchorDate"
|
||||
@@ -161,11 +166,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import ModalDialog from './ModalDialog.vue'
|
||||
import TimePickerPopover from './TimePickerPopover.vue'
|
||||
import DateInputField from './DateInputField.vue'
|
||||
import { maybeShow as tutorialMaybeShow } from '@/tutorial/controller'
|
||||
import { maybeShow as tutorialMaybeShow, modalTutorialStepId } from '@/tutorial/controller'
|
||||
import {
|
||||
setChoreSchedule,
|
||||
deleteChoreSchedule,
|
||||
@@ -259,11 +264,33 @@ const intervalTime = ref<TimeValue>({
|
||||
const saving = ref(false)
|
||||
const errorMsg = ref<string | null>(null)
|
||||
|
||||
function triggerScheduleTutorial(isDays: boolean) {
|
||||
modalTutorialStepId.value = isDays ? 'schedule-days-chips' : 'schedule-interval-frequency'
|
||||
nextTick(() => {
|
||||
if (isDays) {
|
||||
tutorialMaybeShow(
|
||||
'schedule-days-chips',
|
||||
() => document.querySelector('.day-chips') as HTMLElement | null,
|
||||
)
|
||||
} else {
|
||||
tutorialMaybeShow(
|
||||
'schedule-interval-frequency',
|
||||
() => document.querySelector('.stepper') as HTMLElement | null,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
tutorialMaybeShow(
|
||||
'create-chore-schedule',
|
||||
() => document.querySelector('.day-chips .chip') as HTMLElement | null,
|
||||
)
|
||||
triggerScheduleTutorial(mode.value === 'days')
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
modalTutorialStepId.value = null
|
||||
})
|
||||
|
||||
watch(mode, (newMode) => {
|
||||
triggerScheduleTutorial(newMode === 'days')
|
||||
})
|
||||
|
||||
// ── original snapshot (for dirty detection) ──────────────────────────────────
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import EntityEditForm from '../shared/EntityEditForm.vue'
|
||||
import { maybeShow as tutorialMaybeShow } from '@/tutorial/controller'
|
||||
import '@/assets/styles.css'
|
||||
|
||||
const props = defineProps<{ id?: string }>()
|
||||
@@ -36,6 +37,7 @@ const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!isEdit.value) tutorialMaybeShow('edit-chore-name')
|
||||
if (isEdit.value && props.id) {
|
||||
loading.value = true
|
||||
try {
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ItemList from '../shared/ItemList.vue'
|
||||
import MessageBlock from '@/components/shared/MessageBlock.vue'
|
||||
@@ -45,11 +45,6 @@ import DeleteModal from '../shared/DeleteModal.vue'
|
||||
import type { Task } from '@/common/models'
|
||||
import { TASK_FIELDS } from '@/common/models'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import {
|
||||
maybeShow as tutorialMaybeShow,
|
||||
markStepSeen as tutorialMark,
|
||||
tutorialReady,
|
||||
} from '@/tutorial/controller'
|
||||
|
||||
const $router = useRouter()
|
||||
const showConfirm = ref(false)
|
||||
@@ -61,15 +56,6 @@ function handleModified() {
|
||||
listRef.value?.refresh()
|
||||
}
|
||||
|
||||
watch([countRef, tutorialReady], ([count, ready]) => {
|
||||
if (!ready) return
|
||||
if (count === 0) {
|
||||
tutorialMaybeShow('create-chore', () => document.querySelector('.fab') as HTMLElement | null)
|
||||
} else if (typeof count === 'number' && count > 0) {
|
||||
void tutorialMark('has-created-chore')
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
eventBus.on('task_modified', handleModified)
|
||||
})
|
||||
|
||||
@@ -37,7 +37,7 @@ const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!isEdit.value) tutorialMaybeShow('create-kindness')
|
||||
if (!isEdit.value) tutorialMaybeShow('edit-kindness-name')
|
||||
if (isEdit.value && props.id) {
|
||||
loading.value = true
|
||||
try {
|
||||
|
||||
@@ -37,7 +37,7 @@ const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!isEdit.value) tutorialMaybeShow('create-penalty')
|
||||
if (!isEdit.value) tutorialMaybeShow('edit-penalty-name')
|
||||
if (isEdit.value && props.id) {
|
||||
loading.value = true
|
||||
try {
|
||||
|
||||
@@ -12,6 +12,8 @@ const emit = defineEmits(['update:modelValue', 'add-image'])
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const imageScrollRef = ref<HTMLDivElement | null>(null)
|
||||
const addPhotoBtn = ref<HTMLButtonElement | null>(null)
|
||||
const cameraBtn = ref<HTMLButtonElement | null>(null)
|
||||
const localImageUrl = ref<string | null>(null)
|
||||
const showCamera = ref(false)
|
||||
const cameraStream = ref<MediaStream | null>(null)
|
||||
@@ -160,10 +162,6 @@ onMounted(async () => {
|
||||
} finally {
|
||||
loadingImages.value = false
|
||||
}
|
||||
tutorialMaybeShow(
|
||||
'create-chore-image',
|
||||
() => document.querySelector('.icon-btn') as HTMLElement | null,
|
||||
)
|
||||
})
|
||||
|
||||
async function resizeImageFile(
|
||||
@@ -237,13 +235,14 @@ function updateLocalImage(url: string, file: File) {
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg,.gif,image/png,image/jpeg,image/gif"
|
||||
style="display: none"
|
||||
tabindex="-1"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<div class="image-actions">
|
||||
<button type="button" class="icon-btn" @click="addFromLocal" aria-label="Add from device">
|
||||
<button ref="addPhotoBtn" type="button" class="icon-btn" @click="addFromLocal" aria-label="Add from device">
|
||||
<span class="icon">+</span>
|
||||
</button>
|
||||
<button type="button" class="icon-btn" @click="addFromCamera" aria-label="Add from camera">
|
||||
<button ref="cameraBtn" type="button" class="icon-btn" @click="addFromCamera" aria-label="Add from camera">
|
||||
<span class="icon">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<rect x="3" y="6" width="14" height="10" rx="2" stroke="#667eea" stroke-width="1.5" />
|
||||
|
||||
Reference in New Issue
Block a user