Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06d17e3d34 | |||
| e2bb9cd6b9 | |||
| d147bd6f27 |
57
AGENTS.md
Normal file
57
AGENTS.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
Family chore/reward manager. Flask + TinyDB backend (`backend/`), Vue 3 + TypeScript frontend (`frontend/`). Real-time updates over SSE.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Backend (run from `backend/`)
|
||||||
|
- Activate venv: `source .venv/bin/activate`
|
||||||
|
- Dev server: `python -m flask run --host=0.0.0.0 --port=5000` (entry: `main.py`)
|
||||||
|
- Required env vars: `SECRET_KEY`, `REFRESH_TOKEN_EXPIRY_DAYS`, `DIGEST_TOKEN_SECRET`, `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` — Flask raises `RuntimeError` on boot if any are missing
|
||||||
|
- Optional: `DB_ENV` / `DATA_ENV` (`prod` | `test` | `e2e`) — picks `data/` vs `test_data/` dir (see `config/paths.py`)
|
||||||
|
- Tests: `pytest tests/` — `conftest.py` forces `DB_ENV=test` and sets dummy secrets. Single test: `pytest tests/test_routine_api.py::test_name`
|
||||||
|
- Python imports assume `backend/` is on `sys.path` (set by `conftest.py` / `flask run` cwd). Run pytest from `backend/`.
|
||||||
|
- Create admin user: `python scripts/create_admin.py` (admin role cannot be set via signup)
|
||||||
|
|
||||||
|
### Frontend (run from `frontend/`)
|
||||||
|
- Dev: `npm run dev` (Vite, https://localhost:5173)
|
||||||
|
- Lint: `npm run lint`
|
||||||
|
- Type-check: `npm run type-check`
|
||||||
|
- Unit tests: `npm run test:unit` (Vitest). Single: `npx vitest run path/to/file.spec.ts`
|
||||||
|
- E2E: `npx playwright test` — config auto-starts both `npm run dev` and the Flask backend with `DB_ENV=e2e DATA_ENV=e2e`. Tests live in `e2e/`.
|
||||||
|
- E2E buckets are Playwright projects (see `playwright.config.ts`) targeting directories under `e2e/mode_parent/`
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### API routing — the `/api` prefix
|
||||||
|
- Frontend nginx (and Vite dev proxy) strips `/api` before forwarding. **Backend routes must NOT include `/api`.** Backend defines `@app.route('/user')`, frontend calls `/api/user`.
|
||||||
|
- `auth_api` is the only blueprint registered with a prefix: `url_prefix='/auth'` in `main.py:67`.
|
||||||
|
- API errors return `{ error, code }` (codes in `backend/api/error_codes.py`). Frontend extracts them via `parseErrorResponse(res)` in `src/common/api.ts`.
|
||||||
|
|
||||||
|
### Models — strict 1:1 parity
|
||||||
|
- Python `@dataclass`es in `backend/models/`. TypeScript interfaces in `frontend/src/common/models.ts`. Any model change requires updating both.
|
||||||
|
- Persistence is TinyDB via the thread-safe `LockedTable` wrapper (`backend/db/db.py`). Operate on model instances with `from_dict()` / `to_dict()` — never raw dicts.
|
||||||
|
|
||||||
|
### SSE event bus — mandatory for every mutation
|
||||||
|
- Every backend mutation (add/edit/delete/trigger) **must** call `send_event_for_current_user` from `api/utils.py`. Event types in `backend/events/types/` are mirrored in `frontend/src/common/backendEvents.ts`.
|
||||||
|
- Frontend: register listeners in `onMounted`, clean up in `onUnmounted`. SSE endpoint is `/events`.
|
||||||
|
|
||||||
|
### Background schedulers (started in `main.py` at boot)
|
||||||
|
- `start_deletion_scheduler` — runs hourly, deletes accounts marked for deletion after threshold
|
||||||
|
- `start_digest_scheduler` — email digests
|
||||||
|
- `start_state_expiry_scheduler` — expires stale state
|
||||||
|
- `start_chore_expiry_notification_scheduler` — chore expiry notifications
|
||||||
|
|
||||||
|
## Frontend conventions
|
||||||
|
- SFC file order: `<template>` → `<script>` → `<style scoped>`. TypeScript only in `<script>`. All styles must be `scoped`.
|
||||||
|
- Colors/spacing: use only `:root` CSS variables from `colors.css`. No hardcoded hex/px for themed properties.
|
||||||
|
- Layout shells: `ParentLayout` for admin/management, `ChildLayout` for child dashboard/focus.
|
||||||
|
- Images: models carry `image_id`; frontend resolves to `image_url` for rendering.
|
||||||
|
|
||||||
|
## Testing gotchas
|
||||||
|
- E2E tests use pre-authenticated sessions via `storageState` in `playwright.config.ts` — do **not** navigate to `/auth/login`. Import `E2E_EMAIL` / `E2E_PASSWORD` from `e2e/e2e-constants.ts`.
|
||||||
|
- E2E buckets that mutate shared state (default tasks, delete-account, create-child) use isolated users. Preserve this pattern when adding new buckets.
|
||||||
|
- Backend tests: `conftest.py` sets `DB_ENV=test` + dummy secrets. Test DB lands in `test_data/db/`, never touches production `data/`.
|
||||||
|
|
||||||
|
## Feature specs
|
||||||
|
Specs live in `.github/specs/`. If a spec has a checklist, all items must be marked done before the feature is complete.
|
||||||
@@ -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,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<BackendEventsListener />
|
<BackendEventsListener />
|
||||||
<router-view />
|
<router-view />
|
||||||
|
<TutorialOverlay />
|
||||||
|
<TutorialChildModeOffer />
|
||||||
|
<HelpButton />
|
||||||
</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 HelpButton from '@/tutorial/HelpButton.vue'
|
||||||
import { checkAuth } from '@/stores/auth'
|
import { checkAuth } from '@/stores/auth'
|
||||||
|
|
||||||
checkAuth()
|
checkAuth()
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||||
import App from '../App.vue'
|
import App from '../App.vue'
|
||||||
|
|
||||||
|
const mockRouter = createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [{ path: '/', name: 'Home', component: { template: '<div />' } }],
|
||||||
|
})
|
||||||
|
|
||||||
describe('App', () => {
|
describe('App', () => {
|
||||||
it('mounts renders properly', () => {
|
it('mounts renders properly', () => {
|
||||||
const wrapper = mount(App, {
|
const wrapper = mount(App, {
|
||||||
global: {
|
global: {
|
||||||
|
plugins: [mockRouter],
|
||||||
stubs: {
|
stubs: {
|
||||||
'router-view': {
|
'router-view': {
|
||||||
template: '<div>You did it!</div>',
|
template: '<div>You did it!</div>',
|
||||||
|
|||||||
@@ -34,6 +34,42 @@ vi.mock('../services/pushSubscription', () => ({
|
|||||||
getPushPermissionState: vi.fn().mockReturnValue('default'),
|
getPushPermissionState: vi.fn().mockReturnValue('default'),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
function stubUserProfile() {
|
||||||
|
return {
|
||||||
|
template: '<div class="user-profile"><slot /></div>',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubModalDialog() {
|
||||||
|
return {
|
||||||
|
template: '<div class="mock-modal" v-if="show"><slot /></div>',
|
||||||
|
props: ['show'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubImagePicker() {
|
||||||
|
return {
|
||||||
|
template: '<div class="mock-image-picker" />',
|
||||||
|
props: ['modelValue', 'imageType'],
|
||||||
|
emits: ['update:modelValue', 'add-image'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubToggleField() {
|
||||||
|
return {
|
||||||
|
template: '<div class="mock-toggle-field" />',
|
||||||
|
props: ['label', 'modelValue', 'disabled', 'description', 'error'],
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubProfileSection() {
|
||||||
|
return {
|
||||||
|
template: '<div class="mock-profile-section"><slot /></div>',
|
||||||
|
props: ['title', 'defaultOpen'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe('UserProfile - Delete Account', () => {
|
describe('UserProfile - Delete Account', () => {
|
||||||
let wrapper: VueWrapper<any>
|
let wrapper: VueWrapper<any>
|
||||||
|
|
||||||
@@ -57,31 +93,24 @@ describe('UserProfile - Delete Account', () => {
|
|||||||
global: {
|
global: {
|
||||||
plugins: [mockRouter],
|
plugins: [mockRouter],
|
||||||
stubs: {
|
stubs: {
|
||||||
EntityEditForm: {
|
ProfileSection: stubProfileSection(),
|
||||||
template:
|
ImagePicker: stubImagePicker(),
|
||||||
'<div><slot name="custom-field-email" :modelValue="\'test@example.com\'" /></div>',
|
ToggleField: stubToggleField(),
|
||||||
},
|
ModalDialog: stubModalDialog(),
|
||||||
ModalDialog: {
|
|
||||||
template: '<div class="mock-modal" v-if="show"><slot /></div>',
|
|
||||||
props: ['show'],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders Delete My Account button', async () => {
|
it('renders Delete My Account button', async () => {
|
||||||
// Wait for component to mount and render
|
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
// Test the functionality exists by calling the method directly
|
|
||||||
expect(wrapper.vm.openDeleteWarning).toBeDefined()
|
expect(wrapper.vm.openDeleteWarning).toBeDefined()
|
||||||
expect(wrapper.vm.confirmDeleteAccount).toBeDefined()
|
expect(wrapper.vm.confirmDeleteAccount).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('opens warning modal when Delete My Account button is clicked', async () => {
|
it('opens warning modal when Delete My Account button is clicked', async () => {
|
||||||
// Test by calling the method directly
|
|
||||||
wrapper.vm.openDeleteWarning()
|
wrapper.vm.openDeleteWarning()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
@@ -91,21 +120,19 @@ describe('UserProfile - Delete Account', () => {
|
|||||||
|
|
||||||
it('Delete button in warning modal is disabled until email matches', async () => {
|
it('Delete button in warning modal is disabled until email matches', async () => {
|
||||||
// Set initial email
|
// Set initial email
|
||||||
wrapper.vm.initialData.email = 'test@example.com'
|
wrapper.vm.email = 'test@example.com'
|
||||||
|
|
||||||
// Open warning modal
|
// Open warning modal
|
||||||
await wrapper.vm.openDeleteWarning()
|
await wrapper.vm.openDeleteWarning()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
// Find modal delete button (we need to check :disabled binding)
|
|
||||||
// Since we're using a stub, we'll test the logic directly
|
|
||||||
wrapper.vm.confirmEmail = 'wrong@example.com'
|
wrapper.vm.confirmEmail = 'wrong@example.com'
|
||||||
await nextTick()
|
await nextTick()
|
||||||
expect(wrapper.vm.confirmEmail).not.toBe(wrapper.vm.initialData.email)
|
expect(wrapper.vm.confirmEmail).not.toBe(wrapper.vm.email)
|
||||||
|
|
||||||
wrapper.vm.confirmEmail = 'test@example.com'
|
wrapper.vm.confirmEmail = 'test@example.com'
|
||||||
await nextTick()
|
await nextTick()
|
||||||
expect(wrapper.vm.confirmEmail).toBe(wrapper.vm.initialData.email)
|
expect(wrapper.vm.confirmEmail).toBe(wrapper.vm.email)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('calls API when confirmed with correct email', async () => {
|
it('calls API when confirmed with correct email', async () => {
|
||||||
@@ -115,7 +142,7 @@ describe('UserProfile - Delete Account', () => {
|
|||||||
}
|
}
|
||||||
;(global.fetch as any).mockResolvedValueOnce(mockResponse)
|
;(global.fetch as any).mockResolvedValueOnce(mockResponse)
|
||||||
|
|
||||||
wrapper.vm.initialData.email = 'test@example.com'
|
wrapper.vm.email = 'test@example.com'
|
||||||
wrapper.vm.confirmEmail = 'test@example.com'
|
wrapper.vm.confirmEmail = 'test@example.com'
|
||||||
|
|
||||||
await wrapper.vm.confirmDeleteAccount()
|
await wrapper.vm.confirmDeleteAccount()
|
||||||
@@ -132,7 +159,7 @@ describe('UserProfile - Delete Account', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('does not call API if email is invalid format', async () => {
|
it('does not call API if email is invalid format', async () => {
|
||||||
wrapper.vm.initialData.email = 'test@example.com'
|
wrapper.vm.email = 'test@example.com'
|
||||||
wrapper.vm.confirmEmail = 'invalid-email'
|
wrapper.vm.confirmEmail = 'invalid-email'
|
||||||
|
|
||||||
await wrapper.vm.confirmDeleteAccount()
|
await wrapper.vm.confirmDeleteAccount()
|
||||||
@@ -149,7 +176,7 @@ describe('UserProfile - Delete Account', () => {
|
|||||||
}
|
}
|
||||||
;(global.fetch as any).mockResolvedValueOnce(mockResponse)
|
;(global.fetch as any).mockResolvedValueOnce(mockResponse)
|
||||||
|
|
||||||
wrapper.vm.initialData.email = 'test@example.com'
|
wrapper.vm.email = 'test@example.com'
|
||||||
wrapper.vm.confirmEmail = 'test@example.com'
|
wrapper.vm.confirmEmail = 'test@example.com'
|
||||||
|
|
||||||
await wrapper.vm.confirmDeleteAccount()
|
await wrapper.vm.confirmDeleteAccount()
|
||||||
@@ -166,7 +193,7 @@ describe('UserProfile - Delete Account', () => {
|
|||||||
return { ok: true, json: async () => ({ success: true }) }
|
return { ok: true, json: async () => ({ success: true }) }
|
||||||
})
|
})
|
||||||
|
|
||||||
wrapper.vm.initialData.email = 'test@example.com'
|
wrapper.vm.email = 'test@example.com'
|
||||||
wrapper.vm.confirmEmail = 'test@example.com'
|
wrapper.vm.confirmEmail = 'test@example.com'
|
||||||
|
|
||||||
await wrapper.vm.confirmDeleteAccount()
|
await wrapper.vm.confirmDeleteAccount()
|
||||||
@@ -180,7 +207,7 @@ describe('UserProfile - Delete Account', () => {
|
|||||||
json: async () => ({ success: true }),
|
json: async () => ({ success: true }),
|
||||||
})
|
})
|
||||||
|
|
||||||
wrapper.vm.initialData.email = 'test@example.com'
|
wrapper.vm.email = 'test@example.com'
|
||||||
wrapper.vm.confirmEmail = 'test@example.com'
|
wrapper.vm.confirmEmail = 'test@example.com'
|
||||||
|
|
||||||
await wrapper.vm.confirmDeleteAccount()
|
await wrapper.vm.confirmDeleteAccount()
|
||||||
@@ -196,7 +223,7 @@ describe('UserProfile - Delete Account', () => {
|
|||||||
json: async () => ({ error: 'fail', code: 'ERROR' }),
|
json: async () => ({ error: 'fail', code: 'ERROR' }),
|
||||||
})
|
})
|
||||||
|
|
||||||
wrapper.vm.initialData.email = 'test@example.com'
|
wrapper.vm.email = 'test@example.com'
|
||||||
wrapper.vm.confirmEmail = 'test@example.com'
|
wrapper.vm.confirmEmail = 'test@example.com'
|
||||||
|
|
||||||
await wrapper.vm.confirmDeleteAccount()
|
await wrapper.vm.confirmDeleteAccount()
|
||||||
@@ -208,7 +235,7 @@ describe('UserProfile - Delete Account', () => {
|
|||||||
it('clears suppressForceLogout on network error', async () => {
|
it('clears suppressForceLogout on network error', async () => {
|
||||||
;(global.fetch as any).mockRejectedValueOnce(new Error('Network error'))
|
;(global.fetch as any).mockRejectedValueOnce(new Error('Network error'))
|
||||||
|
|
||||||
wrapper.vm.initialData.email = 'test@example.com'
|
wrapper.vm.email = 'test@example.com'
|
||||||
wrapper.vm.confirmEmail = 'test@example.com'
|
wrapper.vm.confirmEmail = 'test@example.com'
|
||||||
|
|
||||||
await wrapper.vm.confirmDeleteAccount()
|
await wrapper.vm.confirmDeleteAccount()
|
||||||
@@ -228,7 +255,7 @@ describe('UserProfile - Delete Account', () => {
|
|||||||
}
|
}
|
||||||
;(global.fetch as any).mockResolvedValueOnce(mockResponse)
|
;(global.fetch as any).mockResolvedValueOnce(mockResponse)
|
||||||
|
|
||||||
wrapper.vm.initialData.email = 'test@example.com'
|
wrapper.vm.email = 'test@example.com'
|
||||||
wrapper.vm.confirmEmail = 'test@example.com'
|
wrapper.vm.confirmEmail = 'test@example.com'
|
||||||
|
|
||||||
await wrapper.vm.confirmDeleteAccount()
|
await wrapper.vm.confirmDeleteAccount()
|
||||||
@@ -242,7 +269,7 @@ describe('UserProfile - Delete Account', () => {
|
|||||||
it('shows error modal on network error', async () => {
|
it('shows error modal on network error', async () => {
|
||||||
;(global.fetch as any).mockRejectedValueOnce(new Error('Network error'))
|
;(global.fetch as any).mockRejectedValueOnce(new Error('Network error'))
|
||||||
|
|
||||||
wrapper.vm.initialData.email = 'test@example.com'
|
wrapper.vm.email = 'test@example.com'
|
||||||
wrapper.vm.confirmEmail = 'test@example.com'
|
wrapper.vm.confirmEmail = 'test@example.com'
|
||||||
|
|
||||||
await wrapper.vm.confirmDeleteAccount()
|
await wrapper.vm.confirmDeleteAccount()
|
||||||
@@ -307,7 +334,7 @@ describe('UserProfile - Delete Account', () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
wrapper.vm.initialData.email = 'test@example.com'
|
wrapper.vm.email = 'test@example.com'
|
||||||
wrapper.vm.confirmEmail = 'test@example.com'
|
wrapper.vm.confirmEmail = 'test@example.com'
|
||||||
|
|
||||||
const deletePromise = wrapper.vm.confirmDeleteAccount()
|
const deletePromise = wrapper.vm.confirmDeleteAccount()
|
||||||
@@ -344,7 +371,7 @@ describe('UserProfile - Delete Account', () => {
|
|||||||
}
|
}
|
||||||
;(global.fetch as any).mockResolvedValueOnce(mockResponse)
|
;(global.fetch as any).mockResolvedValueOnce(mockResponse)
|
||||||
|
|
||||||
wrapper.vm.initialData.email = 'test@example.com'
|
wrapper.vm.email = 'test@example.com'
|
||||||
wrapper.vm.confirmEmail = 'test@example.com'
|
wrapper.vm.confirmEmail = 'test@example.com'
|
||||||
|
|
||||||
await wrapper.vm.confirmDeleteAccount()
|
await wrapper.vm.confirmDeleteAccount()
|
||||||
@@ -354,7 +381,7 @@ describe('UserProfile - Delete Account', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('UserProfile - Profile Update', () => {
|
describe('UserProfile - Auto-save', () => {
|
||||||
let wrapper: VueWrapper<any>
|
let wrapper: VueWrapper<any>
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -369,90 +396,57 @@ describe('UserProfile - Profile Update', () => {
|
|||||||
first_name: 'Test',
|
first_name: 'Test',
|
||||||
last_name: 'User',
|
last_name: 'User',
|
||||||
email: 'test@example.com',
|
email: 'test@example.com',
|
||||||
|
email_digest_enabled: true,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Mount component with router
|
|
||||||
wrapper = mount(UserProfile, {
|
wrapper = mount(UserProfile, {
|
||||||
global: {
|
global: {
|
||||||
plugins: [mockRouter],
|
plugins: [mockRouter],
|
||||||
stubs: {
|
stubs: {
|
||||||
EntityEditForm: {
|
ProfileSection: stubProfileSection(),
|
||||||
template: '<div class="mock-form"><slot /></div>',
|
ImagePicker: stubImagePicker(),
|
||||||
props: ['initialData', 'fields', 'loading', 'error', 'isEdit', 'entityLabel', 'title'],
|
ToggleField: stubToggleField(),
|
||||||
emits: ['submit', 'cancel', 'add-image'],
|
ModalDialog: stubModalDialog(),
|
||||||
},
|
|
||||||
ModalDialog: {
|
|
||||||
template: '<div class="mock-modal"><slot /></div>',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('updates initialData after successful profile save', async () => {
|
it('saveNames sends PUT with first and last name', async () => {
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
// Initial image_id should be set from mount
|
;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) })
|
||||||
expect(wrapper.vm.initialData.image_id).toBe('initial-image-id')
|
|
||||||
|
|
||||||
// Mock successful save response
|
wrapper.vm.firstName = 'Updated'
|
||||||
;(global.fetch as any).mockResolvedValueOnce({
|
wrapper.vm.lastName = 'Name'
|
||||||
ok: true,
|
await wrapper.vm.saveNames()
|
||||||
json: async () => ({}),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Simulate form submission with new image_id
|
|
||||||
const newFormData = {
|
|
||||||
image_id: 'new-image-id',
|
|
||||||
first_name: 'Updated',
|
|
||||||
last_name: 'Name',
|
|
||||||
email: 'test@example.com',
|
|
||||||
}
|
|
||||||
|
|
||||||
await wrapper.vm.handleSubmit(newFormData)
|
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
// initialData should now be updated to match the saved form
|
const putCall = (global.fetch as any).mock.calls.find((c: any[]) => c[1]?.method === 'PUT')
|
||||||
expect(wrapper.vm.initialData.image_id).toBe('new-image-id')
|
expect(putCall).toBeDefined()
|
||||||
expect(wrapper.vm.initialData.first_name).toBe('Updated')
|
const body = JSON.parse(putCall[1].body)
|
||||||
expect(wrapper.vm.initialData.last_name).toBe('Name')
|
expect(body.first_name).toBe('Updated')
|
||||||
|
expect(body.last_name).toBe('Name')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('allows dirty detection after save when reverting to original value', async () => {
|
it('saveImage sends PUT with image_id', async () => {
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
// Start with initial-image-id
|
;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) })
|
||||||
expect(wrapper.vm.initialData.image_id).toBe('initial-image-id')
|
|
||||||
|
|
||||||
// Mock successful save
|
await wrapper.vm.saveImage('new-image-id')
|
||||||
;(global.fetch as any).mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({}),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Change and save to new-image-id
|
|
||||||
await wrapper.vm.handleSubmit({
|
|
||||||
image_id: 'new-image-id',
|
|
||||||
first_name: 'Test',
|
|
||||||
last_name: 'User',
|
|
||||||
email: 'test@example.com',
|
|
||||||
})
|
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
// initialData should now be new-image-id
|
const putCall = (global.fetch as any).mock.calls.find((c: any[]) => c[1]?.method === 'PUT')
|
||||||
expect(wrapper.vm.initialData.image_id).toBe('new-image-id')
|
expect(putCall).toBeDefined()
|
||||||
|
const body = JSON.parse(putCall[1].body)
|
||||||
// Now if user changes back to initial-image-id, it should be detected as different
|
expect(body.image_id).toBe('new-image-id')
|
||||||
// (because initialData is now new-image-id)
|
|
||||||
const currentInitial = wrapper.vm.initialData.image_id
|
|
||||||
expect(currentInitial).toBe('new-image-id')
|
|
||||||
expect(currentInitial).not.toBe('initial-image-id')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('handles image upload during profile save', async () => {
|
it('uploadLocalImage uploads file then saves image_id', async () => {
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
@@ -471,24 +465,18 @@ describe('UserProfile - Profile Update', () => {
|
|||||||
json: async () => ({}),
|
json: async () => ({}),
|
||||||
})
|
})
|
||||||
|
|
||||||
await wrapper.vm.handleSubmit({
|
await wrapper.vm.uploadLocalImage()
|
||||||
image_id: 'local-upload',
|
|
||||||
first_name: 'Test',
|
|
||||||
last_name: 'User',
|
|
||||||
email: 'test@example.com',
|
|
||||||
})
|
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
// Should have called image upload
|
// Should have called image upload
|
||||||
expect(global.fetch).toHaveBeenCalledWith(
|
const uploadCall = (global.fetch as any).mock.calls.find(
|
||||||
'/api/image/upload',
|
(c: any[]) => c[0] === '/api/image/upload',
|
||||||
expect.objectContaining({
|
|
||||||
method: 'POST',
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
expect(uploadCall).toBeDefined()
|
||||||
|
expect(uploadCall[1].method).toBe('POST')
|
||||||
|
|
||||||
// initialData should be updated with uploaded image ID
|
// imageId should be updated
|
||||||
expect(wrapper.vm.initialData.image_id).toBe('uploaded-image-id')
|
expect(wrapper.vm.imageId).toBe('uploaded-image-id')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows error message on failed image upload', async () => {
|
it('shows error message on failed image upload', async () => {
|
||||||
@@ -504,57 +492,25 @@ describe('UserProfile - Profile Update', () => {
|
|||||||
status: 500,
|
status: 500,
|
||||||
})
|
})
|
||||||
|
|
||||||
await wrapper.vm.handleSubmit({
|
await wrapper.vm.uploadLocalImage()
|
||||||
image_id: 'local-upload',
|
|
||||||
first_name: 'Test',
|
|
||||||
last_name: 'User',
|
|
||||||
email: 'test@example.com',
|
|
||||||
})
|
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.vm.errorMsg).toBe('Failed to upload image.')
|
expect(wrapper.vm.errorMsg).toBe('Failed to upload image.')
|
||||||
expect(wrapper.vm.loading).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows success modal after profile update', async () => {
|
|
||||||
await flushPromises()
|
|
||||||
await nextTick()
|
|
||||||
;(global.fetch as any).mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({}),
|
|
||||||
})
|
|
||||||
|
|
||||||
await wrapper.vm.handleSubmit({
|
|
||||||
image_id: 'some-image-id',
|
|
||||||
first_name: 'Test',
|
|
||||||
last_name: 'User',
|
|
||||||
email: 'test@example.com',
|
|
||||||
})
|
|
||||||
await flushPromises()
|
|
||||||
|
|
||||||
expect(wrapper.vm.showModal).toBe(true)
|
|
||||||
expect(wrapper.vm.modalTitle).toBe('Profile Updated')
|
|
||||||
expect(wrapper.vm.modalMessage).toBe('Your profile was updated successfully.')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows error message on failed profile update', async () => {
|
it('shows error message on failed profile update', async () => {
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
;(global.fetch as any).mockResolvedValueOnce({
|
;(global.fetch as any).mockResolvedValueOnce({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 500,
|
status: 500,
|
||||||
})
|
})
|
||||||
|
|
||||||
await wrapper.vm.handleSubmit({
|
await wrapper.vm.saveNames()
|
||||||
image_id: 'some-image-id',
|
|
||||||
first_name: 'Test',
|
|
||||||
last_name: 'User',
|
|
||||||
email: 'test@example.com',
|
|
||||||
})
|
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.vm.errorMsg).toBe('Failed to update profile.')
|
expect(wrapper.vm.errorMsg).toBe('Failed to update profile.')
|
||||||
expect(wrapper.vm.loading).toBe(false)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -576,14 +532,10 @@ describe('UserProfile - Notification Toggles', () => {
|
|||||||
global: {
|
global: {
|
||||||
plugins: [mockRouter],
|
plugins: [mockRouter],
|
||||||
stubs: {
|
stubs: {
|
||||||
EntityEditForm: {
|
ProfileSection: stubProfileSection(),
|
||||||
template:
|
ImagePicker: stubImagePicker(),
|
||||||
'<div><slot name="custom-field-email" :modelValue="\'test@example.com\'" /></div>',
|
ToggleField: stubToggleField(),
|
||||||
},
|
ModalDialog: stubModalDialog(),
|
||||||
ModalDialog: {
|
|
||||||
template: '<div class="mock-modal" v-if="show"><slot /></div>',
|
|
||||||
props: ['show'],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -598,64 +550,38 @@ describe('UserProfile - Notification Toggles', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('initializes email_digest_enabled to true in initialData when profile returns true', async () => {
|
it('initializes emailDigestEnabled to true when profile returns true', async () => {
|
||||||
wrapper = mountWithDigest(true)
|
wrapper = mountWithDigest(true)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
expect(wrapper.vm.initialData.email_digest_enabled).toBe(true)
|
expect(wrapper.vm.emailDigestEnabled).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('initializes email_digest_enabled to false in initialData when profile returns false', async () => {
|
it('initializes emailDigestEnabled to false when profile returns false', async () => {
|
||||||
wrapper = mountWithDigest(false)
|
wrapper = mountWithDigest(false)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
expect(wrapper.vm.initialData.email_digest_enabled).toBe(false)
|
expect(wrapper.vm.emailDigestEnabled).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('initializes push_enabled to false in initialData when not subscribed', async () => {
|
it('initializes pushEnabled to false when not subscribed', async () => {
|
||||||
wrapper = mountWithDigest(true)
|
wrapper = mountWithDigest(true)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
expect(wrapper.vm.initialData.push_enabled).toBe(false)
|
expect(wrapper.vm.pushEnabled).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('fields array includes email_digest_enabled as toggle type', async () => {
|
it('onToggleDigest sends PUT with new value', async () => {
|
||||||
wrapper = mountWithDigest(true)
|
wrapper = mountWithDigest(true)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
const digestField = wrapper.vm.fields.find((f: any) => f.name === 'email_digest_enabled')
|
|
||||||
expect(digestField).toBeDefined()
|
|
||||||
expect(digestField.type).toBe('toggle')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('fields array includes push_enabled as toggle type', async () => {
|
|
||||||
wrapper = mountWithDigest(true)
|
|
||||||
await flushPromises()
|
|
||||||
await nextTick()
|
|
||||||
|
|
||||||
const pushField = wrapper.vm.fields.find((f: any) => f.name === 'push_enabled')
|
|
||||||
expect(pushField).toBeDefined()
|
|
||||||
expect(pushField.type).toBe('toggle')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('profile PUT includes email_digest_enabled when changed on submit', async () => {
|
|
||||||
wrapper = mountWithDigest(true)
|
|
||||||
await flushPromises()
|
|
||||||
await nextTick()
|
|
||||||
;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) })
|
;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) })
|
||||||
|
|
||||||
await wrapper.vm.handleSubmit({
|
await wrapper.vm.onToggleDigest(false)
|
||||||
image_id: null,
|
|
||||||
first_name: 'Test',
|
|
||||||
last_name: 'User',
|
|
||||||
email: 'test@example.com',
|
|
||||||
email_digest_enabled: false,
|
|
||||||
push_enabled: false,
|
|
||||||
})
|
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const putCall = (global.fetch as any).mock.calls.find((c: any[]) => c[1]?.method === 'PUT')
|
const putCall = (global.fetch as any).mock.calls.find((c: any[]) => c[1]?.method === 'PUT')
|
||||||
@@ -664,25 +590,20 @@ describe('UserProfile - Notification Toggles', () => {
|
|||||||
expect(body.email_digest_enabled).toBe(false)
|
expect(body.email_digest_enabled).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('profile PUT omits email_digest_enabled when unchanged on submit', async () => {
|
it('onTogglePush sends PUT with new value and applies push change', async () => {
|
||||||
wrapper = mountWithDigest(true)
|
wrapper = mountWithDigest(true)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) })
|
;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) })
|
||||||
|
|
||||||
await wrapper.vm.handleSubmit({
|
expect(wrapper.vm.pushEnabled).toBe(false)
|
||||||
image_id: null,
|
await wrapper.vm.onTogglePush(true)
|
||||||
first_name: 'Test',
|
|
||||||
last_name: 'User',
|
|
||||||
email: 'test@example.com',
|
|
||||||
email_digest_enabled: true, // same as initial
|
|
||||||
push_enabled: false,
|
|
||||||
})
|
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const putCall = (global.fetch as any).mock.calls.find((c: any[]) => c[1]?.method === 'PUT')
|
const putCall = (global.fetch as any).mock.calls.find((c: any[]) => c[1]?.method === 'PUT')
|
||||||
expect(putCall).toBeDefined()
|
expect(putCall).toBeDefined()
|
||||||
const body = JSON.parse(putCall[1].body)
|
const body = JSON.parse(putCall[1].body)
|
||||||
expect(body.email_digest_enabled).toBeUndefined()
|
expect(body.push_notifications_enabled).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -19,6 +19,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 router = useRouter()
|
const router = useRouter()
|
||||||
@@ -55,6 +56,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('edit-child-name')
|
||||||
if (isEdit.value && props.id) {
|
if (isEdit.value && props.id) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ import {
|
|||||||
triggerRoutineAsParent,
|
triggerRoutineAsParent,
|
||||||
} from '@/common/api'
|
} from '@/common/api'
|
||||||
import { eventBus } from '@/common/eventBus'
|
import { eventBus } from '@/common/eventBus'
|
||||||
|
import {
|
||||||
|
maybeShow as tutorialMaybeShow,
|
||||||
|
activeStep as tutorialActiveStep,
|
||||||
|
modalTutorialStepId,
|
||||||
|
} from '@/tutorial/controller'
|
||||||
import '@/assets/styles.css'
|
import '@/assets/styles.css'
|
||||||
import type {
|
import type {
|
||||||
Task,
|
Task,
|
||||||
@@ -108,6 +113,9 @@ const selectedChoreId = ref<string | null>(null)
|
|||||||
const menuPosition = ref({ top: 0, left: 0 })
|
const menuPosition = ref({ top: 0, left: 0 })
|
||||||
const kebabBtnRefs = ref<Map<string, HTMLElement>>(new Map())
|
const kebabBtnRefs = ref<Map<string, HTMLElement>>(new Map())
|
||||||
|
|
||||||
|
// Tutorial auto-demo state
|
||||||
|
const tutorialHighlightedItemId = ref<string | null>(null)
|
||||||
|
|
||||||
// Schedule modal
|
// Schedule modal
|
||||||
const showScheduleModal = ref(false)
|
const showScheduleModal = ref(false)
|
||||||
const scheduleTarget = ref<ChildTask | null>(null)
|
const scheduleTarget = ref<ChildTask | null>(null)
|
||||||
@@ -426,10 +434,45 @@ function openRoutineMenu(routineId: string, e: MouseEvent) {
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
const btn = routineKebabBtnRefs.value.get(routineId)
|
const btn = routineKebabBtnRefs.value.get(routineId)
|
||||||
if (btn) {
|
if (btn) {
|
||||||
|
btn.scrollIntoView({ block: 'center', behavior: 'auto' })
|
||||||
const rect = btn.getBoundingClientRect()
|
const rect = btn.getBoundingClientRect()
|
||||||
menuPosition.value = { top: rect.bottom, left: rect.right - 140 }
|
menuPosition.value = { top: rect.bottom, left: rect.right - 140 }
|
||||||
}
|
}
|
||||||
activeRoutineMenuFor.value = routineId
|
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() {
|
function closeRoutineMenu() {
|
||||||
@@ -665,7 +708,10 @@ const onDocClick = (e: MouseEvent) => {
|
|||||||
node.classList.contains('kebab-menu')
|
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
|
activeMenuFor.value = null
|
||||||
activeRoutineMenuFor.value = null
|
activeRoutineMenuFor.value = null
|
||||||
selectedChoreId.value = null
|
selectedChoreId.value = null
|
||||||
@@ -683,10 +729,45 @@ function openChoreMenu(taskId: string, e: MouseEvent) {
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
const btn = kebabBtnRefs.value.get(taskId)
|
const btn = kebabBtnRefs.value.get(taskId)
|
||||||
if (btn) {
|
if (btn) {
|
||||||
|
btn.scrollIntoView({ block: 'center', behavior: 'auto' })
|
||||||
const rect = btn.getBoundingClientRect()
|
const rect = btn.getBoundingClientRect()
|
||||||
menuPosition.value = { top: rect.bottom, left: rect.right - 140 }
|
menuPosition.value = { top: rect.bottom, left: rect.right - 140 }
|
||||||
}
|
}
|
||||||
activeMenuFor.value = taskId
|
activeMenuFor.value = taskId
|
||||||
|
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) {
|
||||||
|
if (isChoreExpired(task)) {
|
||||||
|
nextTick(() => {
|
||||||
|
tutorialMaybeShow(
|
||||||
|
'chore-kebab-extend-time',
|
||||||
|
() => document.querySelector('[data-tutorial="chore-extend-time"]') as HTMLElement | null,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (isChoreCompletedToday(task)) {
|
||||||
|
nextTick(() => {
|
||||||
|
tutorialMaybeShow(
|
||||||
|
'chore-kebab-reset',
|
||||||
|
() => document.querySelector('[data-tutorial="chore-reset"]') as HTMLElement | null,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeChoreMenu() {
|
function closeChoreMenu() {
|
||||||
@@ -859,6 +940,13 @@ watch(showOverrideModal, async (newVal) => {
|
|||||||
if (newVal) {
|
if (newVal) {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
document.getElementById('custom-value')?.focus()
|
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
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1002,6 +1090,11 @@ 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).
|
||||||
|
// No anchor: this is a general overview so the card stays centered.
|
||||||
|
nextTick(() => {
|
||||||
|
tutorialMaybeShow('select-child')
|
||||||
|
})
|
||||||
}
|
}
|
||||||
loading.value = false
|
loading.value = false
|
||||||
if (scrollToId) {
|
if (scrollToId) {
|
||||||
@@ -1028,6 +1121,96 @@ 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 },
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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(() => {
|
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)
|
||||||
@@ -1304,11 +1487,12 @@ function goToAssignRoutines() {
|
|||||||
@mousedown.stop.prevent
|
@mousedown.stop.prevent
|
||||||
@click.stop
|
@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
|
Edit Points
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="menu-item"
|
class="menu-item"
|
||||||
|
data-tutorial="chore-schedule"
|
||||||
@mousedown.stop.prevent
|
@mousedown.stop.prevent
|
||||||
@click="openScheduleModal(item, $event)"
|
@click="openScheduleModal(item, $event)"
|
||||||
>
|
>
|
||||||
@@ -1317,6 +1501,7 @@ function goToAssignRoutines() {
|
|||||||
<button
|
<button
|
||||||
v-if="isChoreExpired(item)"
|
v-if="isChoreExpired(item)"
|
||||||
class="menu-item"
|
class="menu-item"
|
||||||
|
data-tutorial="chore-extend-time"
|
||||||
@mousedown.stop.prevent
|
@mousedown.stop.prevent
|
||||||
@click="doExtendTime(item, $event)"
|
@click="doExtendTime(item, $event)"
|
||||||
>
|
>
|
||||||
@@ -1325,6 +1510,7 @@ function goToAssignRoutines() {
|
|||||||
<button
|
<button
|
||||||
v-if="isChoreCompletedToday(item)"
|
v-if="isChoreCompletedToday(item)"
|
||||||
class="menu-item"
|
class="menu-item"
|
||||||
|
data-tutorial="chore-reset"
|
||||||
@mousedown.stop.prevent
|
@mousedown.stop.prevent
|
||||||
@click="doResetChore(item, $event)"
|
@click="doResetChore(item, $event)"
|
||||||
>
|
>
|
||||||
@@ -1413,11 +1599,12 @@ function goToAssignRoutines() {
|
|||||||
@mousedown.stop.prevent
|
@mousedown.stop.prevent
|
||||||
@click.stop
|
@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
|
Edit Routine
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="menu-item"
|
class="menu-item"
|
||||||
|
data-tutorial="routine-edit-points kebab-edit-points-cost"
|
||||||
@mousedown.stop.prevent
|
@mousedown.stop.prevent
|
||||||
@click="editRoutinePoints(item)"
|
@click="editRoutinePoints(item)"
|
||||||
>
|
>
|
||||||
@@ -1425,6 +1612,7 @@ function goToAssignRoutines() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="menu-item"
|
class="menu-item"
|
||||||
|
data-tutorial="routine-schedule"
|
||||||
@mousedown.stop.prevent
|
@mousedown.stop.prevent
|
||||||
@click="openRoutineScheduleModal(item, $event)"
|
@click="openRoutineScheduleModal(item, $event)"
|
||||||
>
|
>
|
||||||
@@ -1433,6 +1621,7 @@ function goToAssignRoutines() {
|
|||||||
<button
|
<button
|
||||||
v-if="isRoutineExpired(item)"
|
v-if="isRoutineExpired(item)"
|
||||||
class="menu-item"
|
class="menu-item"
|
||||||
|
data-tutorial="routine-extend-time"
|
||||||
@mousedown.stop.prevent
|
@mousedown.stop.prevent
|
||||||
@click="doExtendRoutineTime(item, $event)"
|
@click="doExtendRoutineTime(item, $event)"
|
||||||
>
|
>
|
||||||
@@ -1441,6 +1630,7 @@ function goToAssignRoutines() {
|
|||||||
<button
|
<button
|
||||||
v-if="isRoutineApprovedToday(item)"
|
v-if="isRoutineApprovedToday(item)"
|
||||||
class="menu-item"
|
class="menu-item"
|
||||||
|
data-tutorial="routine-reset"
|
||||||
@mousedown.stop.prevent
|
@mousedown.stop.prevent
|
||||||
@click="doResetRoutine(item, $event)"
|
@click="doResetRoutine(item, $event)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
156
frontend/src/components/profile/ProfileSection.vue
Normal file
156
frontend/src/components/profile/ProfileSection.vue
Normal file
@@ -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>
|
<template>
|
||||||
<div class="view">
|
<div class="view">
|
||||||
<EntityEditForm
|
<h2>Profile</h2>
|
||||||
entityLabel="User Profile"
|
|
||||||
:fields="fields"
|
<div v-if="loading" class="loading-message">Loading profile...</div>
|
||||||
:initialData="initialData"
|
<div v-else class="profile-card">
|
||||||
:isEdit="true"
|
<div v-if="errorMsg" class="error-banner" aria-live="polite">{{ errorMsg }}</div>
|
||||||
:loading="loading"
|
|
||||||
:error="errorMsg"
|
<ProfileSection title="User" :defaultOpen="true">
|
||||||
:title="'User Profile'"
|
<div class="name-fields" @focusout="handleNameFocusOut">
|
||||||
:fieldErrors="{ push_enabled: pushError }"
|
<div class="field-group">
|
||||||
@submit="handleSubmit"
|
<label for="first-name">First Name</label>
|
||||||
@cancel="router.back"
|
<input
|
||||||
@add-image="onAddImage"
|
id="first-name"
|
||||||
>
|
v-model="firstName"
|
||||||
<template #custom-field-email="{ modelValue }">
|
type="text"
|
||||||
<div class="email-actions">
|
maxlength="64"
|
||||||
<input id="email" type="email" :value="modelValue" disabled class="readonly-input" />
|
: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">
|
<button type="button" class="btn-link btn-link-space" @click="goToChangeParentPin">
|
||||||
Change Parent PIN
|
Change Parent PIN
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" class="btn-link btn-link-space" @click="resetPassword" :disabled="resetting">
|
||||||
type="button"
|
|
||||||
class="btn-link btn-link-space"
|
|
||||||
@click="resetPassword"
|
|
||||||
:disabled="resetting"
|
|
||||||
>
|
|
||||||
Change Password
|
Change Password
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn-link btn-link-space" @click="openDeleteWarning">
|
<button type="button" class="btn-link btn-link-space" @click="openDeleteWarning">
|
||||||
Delete My Account
|
Delete My Account
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</ProfileSection>
|
||||||
</EntityEditForm>
|
|
||||||
|
|
||||||
<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
|
<ModalDialog
|
||||||
v-if="showModal"
|
v-if="showModal"
|
||||||
:title="modalTitle"
|
:title="modalTitle"
|
||||||
@@ -95,14 +152,39 @@
|
|||||||
<button class="btn btn-primary" @click="closeDeleteError">Close</button>
|
<button class="btn btn-primary" @click="closeDeleteError">Close</button>
|
||||||
</div>
|
</div>
|
||||||
</ModalDialog>
|
</ModalDialog>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import EntityEditForm from '../shared/EntityEditForm.vue'
|
import ProfileSection from './ProfileSection.vue'
|
||||||
import ModalDialog from '../shared/ModalDialog.vue'
|
import ImagePicker from '@/components/utils/ImagePicker.vue'
|
||||||
|
import ToggleField from '@/components/shared/ToggleField.vue'
|
||||||
|
import ModalDialog from '@/components/shared/ModalDialog.vue'
|
||||||
import {
|
import {
|
||||||
isSubscribedToPush,
|
isSubscribedToPush,
|
||||||
subscribeToPushWithResult,
|
subscribeToPushWithResult,
|
||||||
@@ -113,19 +195,34 @@ 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()
|
||||||
const loading = ref(false)
|
|
||||||
|
// Profile data
|
||||||
|
const loading = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
const errorMsg = ref('')
|
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)
|
const localImageFile = ref<File | null>(null)
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
const resetting = ref(false)
|
||||||
const showModal = ref(false)
|
const showModal = ref(false)
|
||||||
const modalTitle = ref('')
|
const modalTitle = ref('')
|
||||||
const modalSubtitle = ref('')
|
const modalSubtitle = ref('')
|
||||||
const modalMessage = ref('')
|
const modalMessage = ref('')
|
||||||
|
|
||||||
// Delete account modal state
|
|
||||||
const showDeleteWarning = ref(false)
|
const showDeleteWarning = ref(false)
|
||||||
const confirmEmail = ref('')
|
const confirmEmail = ref('')
|
||||||
const deletingAccount = ref(false)
|
const deletingAccount = ref(false)
|
||||||
@@ -133,46 +230,10 @@ const showDeleteSuccess = ref(false)
|
|||||||
const showDeleteError = ref(false)
|
const showDeleteError = ref(false)
|
||||||
const deleteErrorMessage = ref('')
|
const deleteErrorMessage = ref('')
|
||||||
|
|
||||||
const pushError = ref('')
|
const showRestartConfirm = ref(false)
|
||||||
const pushPermissionDenied = ref(false)
|
const showRestartSuccess = ref(false)
|
||||||
|
|
||||||
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 () => {
|
onMounted(async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -181,14 +242,12 @@ onMounted(async () => {
|
|||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
pushPermissionDenied.value = getPushPermissionState() === 'denied'
|
pushPermissionDenied.value = getPushPermissionState() === 'denied'
|
||||||
const pushSubscribed = await isSubscribedToPush()
|
const pushSubscribed = await isSubscribedToPush()
|
||||||
initialData.value = {
|
firstName.value = data.first_name || ''
|
||||||
image_id: data.image_id || null,
|
lastName.value = data.last_name || ''
|
||||||
first_name: data.first_name || '',
|
imageId.value = data.image_id || null
|
||||||
last_name: data.last_name || '',
|
email.value = data.email || ''
|
||||||
email: data.email || '',
|
emailDigestEnabled.value = data.email_digest_enabled !== false
|
||||||
email_digest_enabled: data.email_digest_enabled !== false,
|
pushEnabled.value = data.push_notifications_enabled !== false && pushSubscribed
|
||||||
push_enabled: data.push_notifications_enabled !== false && pushSubscribed,
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
errorMsg.value = 'Could not load user profile.'
|
errorMsg.value = 'Could not load user profile.'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -196,102 +255,139 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function onAddImage({ id, file }: { id: string; file: File }) {
|
function handleNameFocusOut(event: FocusEvent) {
|
||||||
if (id === 'local-upload') {
|
const wrapper = event.currentTarget as HTMLElement
|
||||||
localImageFile.value = file
|
const relatedTarget = event.relatedTarget as HTMLElement | null
|
||||||
} else {
|
if (relatedTarget && wrapper.contains(relatedTarget)) {
|
||||||
localImageFile.value = null
|
return
|
||||||
initialData.value.image_id = id
|
}
|
||||||
|
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: {
|
// ─── Auto-save: image ───
|
||||||
image_id: string | null
|
function onImageChange(id: string | null) {
|
||||||
first_name: string
|
if (id === 'local-upload') {
|
||||||
last_name: string
|
// localImageFile is set by onAddImage which fires first
|
||||||
email: string
|
uploadLocalImage()
|
||||||
email_digest_enabled?: boolean
|
} else {
|
||||||
push_enabled?: boolean
|
localImageFile.value = null
|
||||||
}) {
|
saveImage(id)
|
||||||
errorMsg.value = ''
|
}
|
||||||
loading.value = true
|
}
|
||||||
|
|
||||||
// Handle image upload if local file
|
function onAddImage({ id, file }: { id: string; file: File }) {
|
||||||
let imageId = form.image_id
|
if (id === 'local-upload') {
|
||||||
if (imageId === 'local-upload' && localImageFile.value) {
|
localImageFile.value = file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadLocalImage() {
|
||||||
|
if (!localImageFile.value) return
|
||||||
|
saving.value = true
|
||||||
|
errorMsg.value = ''
|
||||||
|
try {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', localImageFile.value)
|
formData.append('file', localImageFile.value)
|
||||||
formData.append('type', '1')
|
formData.append('type', '1')
|
||||||
formData.append('permanent', 'true')
|
formData.append('permanent', 'true')
|
||||||
fetch('/api/image/upload', {
|
const resp = await fetch('/api/image/upload', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
})
|
})
|
||||||
.then(async (resp) => {
|
if (!resp.ok) throw new Error('Image upload failed')
|
||||||
if (!resp.ok) throw new Error('Image upload failed')
|
const data = await resp.json()
|
||||||
const data = await resp.json()
|
imageId.value = data.id
|
||||||
imageId = data.id
|
await saveImage(data.id)
|
||||||
// Now update profile
|
} catch {
|
||||||
return updateProfile({
|
errorMsg.value = 'Failed to upload image.'
|
||||||
...form,
|
} finally {
|
||||||
image_id: imageId,
|
saving.value = false
|
||||||
})
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
errorMsg.value = 'Failed to upload image.'
|
|
||||||
loading.value = false
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
updateProfile(form)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateProfile(form: {
|
async function saveImage(id: string | null) {
|
||||||
image_id: string | null
|
saving.value = true
|
||||||
first_name: string
|
errorMsg.value = ''
|
||||||
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
|
|
||||||
try {
|
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', {
|
const res = await fetch('/api/user/profile', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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')
|
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 {
|
} catch {
|
||||||
errorMsg.value = 'Failed to update profile.'
|
errorMsg.value = 'Failed to update profile.'
|
||||||
} finally {
|
} 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,16 +416,13 @@ async function applyPushChange(newValue: boolean): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePasswordModalClose() {
|
// ─── Tutorial toggle ───
|
||||||
const wasProfileUpdate = modalTitle.value === 'Profile Updated'
|
async function onToggleTutorial(val: boolean) {
|
||||||
showModal.value = false
|
await setTutorialEnabled(val)
|
||||||
if (wasProfileUpdate) {
|
|
||||||
router.back()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Password reset ───
|
||||||
async function resetPassword() {
|
async function resetPassword() {
|
||||||
// Show modal immediately with loading message
|
|
||||||
modalTitle.value = 'Change Password'
|
modalTitle.value = 'Change Password'
|
||||||
modalMessage.value = 'Sending password change email...'
|
modalMessage.value = 'Sending password change email...'
|
||||||
modalSubtitle.value = ''
|
modalSubtitle.value = ''
|
||||||
@@ -340,7 +433,7 @@ async function resetPassword() {
|
|||||||
const res = await fetch('/api/auth/request-password-reset', {
|
const res = await fetch('/api/auth/request-password-reset', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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')
|
if (!res.ok) throw new Error('Failed to send reset email')
|
||||||
modalTitle.value = 'Password Change Email Sent'
|
modalTitle.value = 'Password Change Email Sent'
|
||||||
@@ -354,10 +447,16 @@ async function resetPassword() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handlePasswordModalClose() {
|
||||||
|
showModal.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Navigation ───
|
||||||
function goToChangeParentPin() {
|
function goToChangeParentPin() {
|
||||||
router.push({ name: 'ParentPinSetup' })
|
router.push({ name: 'ParentPinSetup' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Delete account ───
|
||||||
function openDeleteWarning() {
|
function openDeleteWarning() {
|
||||||
confirmEmail.value = ''
|
confirmEmail.value = ''
|
||||||
showDeleteWarning.value = true
|
showDeleteWarning.value = true
|
||||||
@@ -370,9 +469,6 @@ function closeDeleteWarning() {
|
|||||||
|
|
||||||
async function confirmDeleteAccount() {
|
async function confirmDeleteAccount() {
|
||||||
if (!isEmailValid(confirmEmail.value)) return
|
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
|
suppressForceLogout.value = true
|
||||||
deletingAccount.value = true
|
deletingAccount.value = true
|
||||||
try {
|
try {
|
||||||
@@ -381,7 +477,6 @@ async function confirmDeleteAccount() {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email: confirmEmail.value }),
|
body: JSON.stringify({ email: confirmEmail.value }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
suppressForceLogout.value = false
|
suppressForceLogout.value = false
|
||||||
const { msg, code } = await parseErrorResponse(res)
|
const { msg, code } = await parseErrorResponse(res)
|
||||||
@@ -394,8 +489,6 @@ async function confirmDeleteAccount() {
|
|||||||
showDeleteError.value = true
|
showDeleteError.value = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success — suppressForceLogout is already set; show confirmation modal
|
|
||||||
showDeleteWarning.value = false
|
showDeleteWarning.value = false
|
||||||
showDeleteSuccess.value = true
|
showDeleteSuccess.value = true
|
||||||
} catch {
|
} catch {
|
||||||
@@ -410,12 +503,10 @@ async function confirmDeleteAccount() {
|
|||||||
|
|
||||||
function handleDeleteSuccess() {
|
function handleDeleteSuccess() {
|
||||||
showDeleteSuccess.value = false
|
showDeleteSuccess.value = false
|
||||||
// Call logout API to clear server cookies
|
|
||||||
fetch('/api/auth/logout', {
|
fetch('/api/auth/logout', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
// Clear client-side auth and redirect, regardless of logout response
|
|
||||||
logoutUser()
|
logoutUser()
|
||||||
router.push('/')
|
router.push('/')
|
||||||
})
|
})
|
||||||
@@ -425,65 +516,134 @@ function closeDeleteError() {
|
|||||||
showDeleteError.value = false
|
showDeleteError.value = false
|
||||||
deleteErrorMessage.value = ''
|
deleteErrorMessage.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Tutorial restart ───
|
||||||
|
function openRestartConfirm() {
|
||||||
|
showRestartConfirm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRestartTutorial() {
|
||||||
|
showRestartConfirm.value = false
|
||||||
|
await resetAllProgress()
|
||||||
|
showRestartSuccess.value = true
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.view {
|
.view {
|
||||||
max-width: 400px;
|
max-width: 420px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card {
|
||||||
background: var(--form-bg);
|
background: var(--form-bg);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 4px 24px var(--form-shadow);
|
box-shadow: 0 4px 24px var(--form-shadow);
|
||||||
padding: 2rem 2.2rem 1.5rem 2.2rem;
|
padding: 0.5rem 1.5rem 1rem;
|
||||||
}
|
|
||||||
/* ...existing styles... */
|
|
||||||
.email-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.success-message {
|
.loading-message {
|
||||||
color: var(--success, #16a34a);
|
text-align: center;
|
||||||
|
color: var(--loading-color, #888);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
padding: 2rem 0;
|
||||||
}
|
}
|
||||||
.error-message {
|
|
||||||
|
.error-banner {
|
||||||
color: var(--error, #e53e3e);
|
color: var(--error, #e53e3e);
|
||||||
font-size: 0.98rem;
|
font-size: 0.95rem;
|
||||||
margin-top: 0.4rem;
|
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%;
|
width: 100%;
|
||||||
padding: 0.6rem;
|
padding: 0.6rem;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
border: 1px solid var(--form-input-border, #e6e6e6);
|
border: 1px solid var(--form-input-border, #e6e6e6);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
background: var(--form-input-bg, #f5f5f5);
|
background: var(--form-input-bg, #fff);
|
||||||
color: var(--form-label, #888);
|
color: var(--text-primary, #222);
|
||||||
box-sizing: border-box;
|
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;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--error, #e53e3e);
|
color: var(--btn-primary, #667eea);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
margin-top: 0.25rem;
|
|
||||||
align-self: flex-start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger-link:hover {
|
.btn-link:hover {
|
||||||
color: var(--error-hover, #c53030);
|
color: var(--btn-primary-hover, #5a67d8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger-link:disabled {
|
.btn-link:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
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 {
|
.email-confirm-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.6rem;
|
padding: 0.6rem;
|
||||||
@@ -500,4 +660,18 @@ function closeDeleteError() {
|
|||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--btn-primary, #4a90e2);
|
border-color: var(--btn-primary, #4a90e2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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>
|
</style>
|
||||||
|
|||||||
@@ -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 }>()
|
||||||
@@ -46,6 +47,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('edit-reward-name')
|
||||||
if (isEdit.value && props.id) {
|
if (isEdit.value && props.id) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
:data-idx="idx"
|
:data-idx="idx"
|
||||||
:class="{ 'drag-over': dragOverIdx === idx, dragging: draggingIdx === 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">
|
<div class="item-left">
|
||||||
<img
|
<img
|
||||||
v-if="item.image_url"
|
v-if="item.image_url"
|
||||||
@@ -43,11 +43,17 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-secondary small-btn"
|
class="btn btn-secondary small-btn"
|
||||||
|
data-tutorial="routine-task-edit"
|
||||||
@click="startEditItem(idx)"
|
@click="startEditItem(idx)"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</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
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,6 +128,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 +168,7 @@ 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('edit-routine-name')
|
||||||
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, nextTick } 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,27 @@ 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) {
|
||||||
|
nextTick(() => {
|
||||||
|
tutorialMaybeShow('create-child')
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
void tutorialMark('has-created-child')
|
||||||
|
nextTick(() => {
|
||||||
|
tutorialMaybeShow('child-points')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +184,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 +241,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)
|
||||||
|
|||||||
@@ -58,7 +58,12 @@
|
|||||||
|
|
||||||
<!-- Selected day exception list -->
|
<!-- Selected day exception list -->
|
||||||
<div v-if="selectedDays.size > 0" class="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>
|
<span class="exception-day-name">{{ DAY_LABELS[idx] }}</span>
|
||||||
<div class="exception-right">
|
<div class="exception-right">
|
||||||
<template v-if="exceptions.has(idx)">
|
<template v-if="exceptions.has(idx)">
|
||||||
@@ -114,7 +119,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="field-label">{{ intervalDays === 1 ? 'day' : 'days' }}</span>
|
<span class="field-label">{{ intervalDays === 1 ? 'day' : 'days' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="interval-row">
|
<div class="interval-row" data-tutorial="schedule-interval-start">
|
||||||
<label class="field-label">Starting on</label>
|
<label class="field-label">Starting on</label>
|
||||||
<DateInputField
|
<DateInputField
|
||||||
:modelValue="anchorDate"
|
:modelValue="anchorDate"
|
||||||
@@ -161,10 +166,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, nextTick, watch } 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, modalTutorialStepId } from '@/tutorial/controller'
|
||||||
import {
|
import {
|
||||||
setChoreSchedule,
|
setChoreSchedule,
|
||||||
deleteChoreSchedule,
|
deleteChoreSchedule,
|
||||||
@@ -258,6 +264,35 @@ const intervalTime = ref<TimeValue>({
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const errorMsg = ref<string | null>(null)
|
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(() => {
|
||||||
|
triggerScheduleTutorial(mode.value === 'days')
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
modalTutorialStepId.value = null
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(mode, (newMode) => {
|
||||||
|
triggerScheduleTutorial(newMode === 'days')
|
||||||
|
})
|
||||||
|
|
||||||
// ── original snapshot (for dirty detection) ──────────────────────────────────
|
// ── original snapshot (for dirty detection) ──────────────────────────────────
|
||||||
|
|
||||||
const origMode = props.schedule?.mode ?? 'days'
|
const origMode = props.schedule?.mode ?? 'days'
|
||||||
|
|||||||
@@ -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('edit-chore-name')
|
||||||
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('edit-kindness-name')
|
||||||
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('edit-penalty-name')
|
||||||
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<{
|
||||||
@@ -11,6 +12,8 @@ const emit = defineEmits(['update:modelValue', 'add-image'])
|
|||||||
|
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
const imageScrollRef = ref<HTMLDivElement | 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 localImageUrl = ref<string | null>(null)
|
||||||
const showCamera = ref(false)
|
const showCamera = ref(false)
|
||||||
const cameraStream = ref<MediaStream | null>(null)
|
const cameraStream = ref<MediaStream | null>(null)
|
||||||
@@ -232,13 +235,14 @@ function updateLocalImage(url: string, file: File) {
|
|||||||
type="file"
|
type="file"
|
||||||
accept=".png,.jpg,.jpeg,.gif,image/png,image/jpeg,image/gif"
|
accept=".png,.jpg,.jpeg,.gif,image/png,image/jpeg,image/gif"
|
||||||
style="display: none"
|
style="display: none"
|
||||||
|
tabindex="-1"
|
||||||
@change="onFileChange"
|
@change="onFileChange"
|
||||||
/>
|
/>
|
||||||
<div class="image-actions">
|
<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>
|
<span class="icon">+</span>
|
||||||
</button>
|
</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">
|
<span class="icon">
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
<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" />
|
<rect x="3" y="6" width="14" height="10" rx="2" stroke="#667eea" stroke-width="1.5" />
|
||||||
|
|||||||
127
frontend/src/tutorial/HelpButton.vue
Normal file
127
frontend/src/tutorial/HelpButton.vue
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
<template>
|
||||||
|
<button
|
||||||
|
v-if="visible"
|
||||||
|
type="button"
|
||||||
|
class="help-fab"
|
||||||
|
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, clearChainProgress, modalTutorialStepId } 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: 'parent-children-list',
|
||||||
|
ChoreView: 'list-chore-help',
|
||||||
|
KindnessView: 'list-kindness-help',
|
||||||
|
PenaltyView: 'list-penalty-help',
|
||||||
|
RewardView: 'list-reward-help',
|
||||||
|
RoutineView: 'list-routine-help',
|
||||||
|
NotificationView: 'notification-click',
|
||||||
|
ParentView: 'select-child',
|
||||||
|
CreateChore: 'edit-chore-name',
|
||||||
|
EditChore: 'edit-chore-name',
|
||||||
|
CreateKindness: 'edit-kindness-name',
|
||||||
|
EditKindness: 'edit-kindness-name',
|
||||||
|
CreatePenalty: 'edit-penalty-name',
|
||||||
|
EditPenalty: 'edit-penalty-name',
|
||||||
|
CreateRoutine: 'edit-routine-name',
|
||||||
|
EditRoutine: 'edit-routine-name',
|
||||||
|
CreateReward: 'edit-reward-name',
|
||||||
|
EditReward: 'edit-reward-name',
|
||||||
|
CreateChild: 'edit-child-name',
|
||||||
|
ChildEditView: 'edit-child-name',
|
||||||
|
ChoreAssignView: 'assign-chore-list',
|
||||||
|
KindnessAssignView: 'assign-kindness-list',
|
||||||
|
PenaltyAssignView: 'assign-penalty-list',
|
||||||
|
RewardAssignView: 'assign-reward-list',
|
||||||
|
RoutineAssignView: 'assign-routine-list',
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const targetStepId = computed<string | null>(() => {
|
||||||
|
// Modals (e.g. ScheduleModal) can override the route-based step.
|
||||||
|
if (modalTutorialStepId.value) return modalTutorialStepId.value
|
||||||
|
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" for the whole chain so every step replays.
|
||||||
|
// The server state is left alone; on dismiss `markStepSeen` simply no-ops.
|
||||||
|
clearChainProgress(id)
|
||||||
|
maybeShow(id)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.help-fab {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 2rem;
|
||||||
|
left: 2rem;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 0;
|
||||||
|
background: var(--btn-secondary, rgba(255, 255, 255, 0.92));
|
||||||
|
color: var(--btn-primary, #667eea);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
z-index: 1300;
|
||||||
|
transition:
|
||||||
|
background 0.18s,
|
||||||
|
box-shadow 0.18s,
|
||||||
|
transform 0.18s;
|
||||||
|
}
|
||||||
|
.help-fab:hover {
|
||||||
|
background: var(--btn-secondary-hover, #e2e8f0);
|
||||||
|
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.25);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
.help-fab:focus-visible {
|
||||||
|
outline: 2px solid var(--primary, #667eea);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.help-fab {
|
||||||
|
bottom: 1rem;
|
||||||
|
left: 1rem;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
background: rgba(255, 255, 255, 0.78);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
.help-fab:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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>
|
||||||
425
frontend/src/tutorial/TutorialOverlay.vue
Normal file
425
frontend/src/tutorial/TutorialOverlay.vue
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
<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
|
||||||
|
? Math.min(r.bottom + CARD_MARGIN, vh - ch - CARD_MARGIN)
|
||||||
|
: Math.max(CARD_MARGIN, Math.min(r.top - ch - CARD_MARGIN, vh - 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()
|
||||||
|
// Scroll the anchor into view before measuring so the spotlight and card
|
||||||
|
// are positioned correctly. Use 'auto' for synchronous scroll — smooth
|
||||||
|
// scroll races the first measure and can leave the card off-screen.
|
||||||
|
const el = resolveAnchor()
|
||||||
|
if (el) {
|
||||||
|
const pos = getComputedStyle(el).position
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
const isHidden = rect.width === 0 && rect.height === 0
|
||||||
|
if (isHidden) {
|
||||||
|
anchorRect.value = null
|
||||||
|
} else if (pos !== 'fixed') {
|
||||||
|
el.scrollIntoView({ behavior: 'auto', block: 'center' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Give the browser one frame to finish the synchronous scroll before
|
||||||
|
// measuring anchor and card positions.
|
||||||
|
await new Promise((r) => requestAnimationFrame(r))
|
||||||
|
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: 10000;
|
||||||
|
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>
|
||||||
242
frontend/src/tutorial/controller.ts
Normal file
242
frontend/src/tutorial/controller.ts
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
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)
|
||||||
|
/** When a modal is open, this holds the step ID that should fire via the `?` button. */
|
||||||
|
export const modalTutorialStepId = ref<string | null>(null)
|
||||||
|
|
||||||
|
const queue: ActiveStep[] = []
|
||||||
|
let hydrated = false
|
||||||
|
|
||||||
|
/** Steps that were locally cleared for replay. Server hydration must not restore them. */
|
||||||
|
const locallyCleared = new Set<string>()
|
||||||
|
|
||||||
|
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') {
|
||||||
|
// Merge server state with local state. Locally-cleared steps must NOT be
|
||||||
|
// restored by the server (prevents replay chains from breaking mid-flight
|
||||||
|
// when a PROFILE_UPDATED SSE arrives).
|
||||||
|
const merged = { ...profile.tutorial_progress }
|
||||||
|
for (const id of locallyCleared) {
|
||||||
|
delete merged[id]
|
||||||
|
}
|
||||||
|
// Overlay any local optimistic seen flags
|
||||||
|
for (const [key, val] of Object.entries(tutorialProgress.value)) {
|
||||||
|
if (val) merged[key] = true
|
||||||
|
}
|
||||||
|
tutorialProgress.value = merged
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
// Steps cleared for manual replay (e.g. via the ? button) are ephemeral:
|
||||||
|
// track them locally so the chain can continue, but don't persist to the
|
||||||
|
// server. Forced intro / JIT auto-steps still hit the server.
|
||||||
|
if (locallyCleared.has(current.def.id)) {
|
||||||
|
tutorialProgress.value = { ...tutorialProgress.value, [current.def.id]: true }
|
||||||
|
} else {
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Chain finished or dismissed: remove this step from the ephemeral guard
|
||||||
|
// so future auto-triggers can persist normally.
|
||||||
|
locallyCleared.delete(current.def.id)
|
||||||
|
promote()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skipSession() {
|
||||||
|
const current = activeStep.value
|
||||||
|
sessionSkipped.value = true
|
||||||
|
queue.length = 0
|
||||||
|
activeStep.value = null
|
||||||
|
if (current) {
|
||||||
|
if (locallyCleared.has(current.def.id)) {
|
||||||
|
tutorialProgress.value = { ...tutorialProgress.value, [current.def.id]: true }
|
||||||
|
locallyCleared.delete(current.def.id)
|
||||||
|
} else {
|
||||||
|
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 }
|
||||||
|
locallyCleared.delete(id)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear local "seen" state for a chain of steps so it can be replayed.
|
||||||
|
* Marks each cleared step so server hydration won't restore it mid-replay. */
|
||||||
|
export function clearChainProgress(startId: string) {
|
||||||
|
let id: string | undefined = startId
|
||||||
|
while (id) {
|
||||||
|
if (tutorialProgress.value[id]) {
|
||||||
|
const progress = { ...tutorialProgress.value }
|
||||||
|
delete progress[id]
|
||||||
|
tutorialProgress.value = progress
|
||||||
|
}
|
||||||
|
locallyCleared.add(id)
|
||||||
|
id = stepRegistry[id]?.next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
715
frontend/src/tutorial/steps.ts
Normal file
715
frontend/src/tutorial/steps.ts
Normal file
@@ -0,0 +1,715 @@
|
|||||||
|
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 child',
|
||||||
|
body: "Let's add your kiddo. Tap the + button to create a profile — you can pick a fun avatar together.",
|
||||||
|
placement: 'above',
|
||||||
|
ctaLabel: 'Got it',
|
||||||
|
anchorSelector: '.fab',
|
||||||
|
},
|
||||||
|
'parent-children-list': {
|
||||||
|
id: 'parent-children-list',
|
||||||
|
title: 'Your children',
|
||||||
|
body: 'This is your children list. Each card shows one of your kids — their photo, age, and points. Tap + to add a new child.',
|
||||||
|
placement: 'above',
|
||||||
|
next: 'child-points',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: '.fab',
|
||||||
|
},
|
||||||
|
'child-points': {
|
||||||
|
id: 'child-points',
|
||||||
|
title: 'Points',
|
||||||
|
body: 'This is how many points your child has earned. They spend these on rewards you assign. Tap the child card to see their details and manage assignments.',
|
||||||
|
placement: 'below',
|
||||||
|
next: 'child-click',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: '.points',
|
||||||
|
},
|
||||||
|
'child-click': {
|
||||||
|
id: 'child-click',
|
||||||
|
title: 'Tap a child',
|
||||||
|
body: 'Tap any child card to open their page. There you can assign chores, kindness acts, rewards, routines, and penalties.',
|
||||||
|
placement: 'below',
|
||||||
|
next: 'child-kebab',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: '.card',
|
||||||
|
},
|
||||||
|
'create-chore': {
|
||||||
|
id: 'create-chore',
|
||||||
|
title: 'Create your 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 ────────────────────────────────────────────────
|
||||||
|
'edit-chore-name': {
|
||||||
|
id: 'edit-chore-name',
|
||||||
|
title: 'Chore Name',
|
||||||
|
body: 'Give the chore a short, clear name your child will understand — like "Clean your room" or "Feed the dog".',
|
||||||
|
placement: 'below',
|
||||||
|
next: 'edit-chore-points',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: 'input#name',
|
||||||
|
},
|
||||||
|
'edit-chore-points': {
|
||||||
|
id: 'edit-chore-points',
|
||||||
|
title: 'Points',
|
||||||
|
body: 'How many points is this chore worth? Bigger or harder chores can be worth more. Your child spends these points on rewards.',
|
||||||
|
placement: 'below',
|
||||||
|
next: 'edit-chore-image',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: 'input#points',
|
||||||
|
},
|
||||||
|
'edit-chore-image': {
|
||||||
|
id: 'edit-chore-image',
|
||||||
|
title: 'Pick a Picture',
|
||||||
|
body: 'Choose an image so your child can spot this chore easily. Tap + to pick from your photos or the camera to take a new one.',
|
||||||
|
placement: 'above',
|
||||||
|
anchorSelector: '.picker',
|
||||||
|
},
|
||||||
|
'create-chore-image-photo': {
|
||||||
|
id: 'create-chore-image-photo',
|
||||||
|
title: 'Choose a photo',
|
||||||
|
body: 'Tap + to pick a picture from your device. A clear photo helps younger kids recognize each chore.',
|
||||||
|
placement: 'above',
|
||||||
|
},
|
||||||
|
'create-chore-image-camera': {
|
||||||
|
id: 'create-chore-image-camera',
|
||||||
|
title: 'Take a photo',
|
||||||
|
body: 'Tap the camera to snap a new picture. A clear photo helps younger kids recognize each chore.',
|
||||||
|
placement: 'above',
|
||||||
|
},
|
||||||
|
// ── Schedule modal — Specific Days chain ─────────────────────────────────
|
||||||
|
'schedule-days-chips': {
|
||||||
|
id: 'schedule-days-chips',
|
||||||
|
title: 'Pick the days',
|
||||||
|
body: 'Tap one or more days to schedule this chore or routine. You can choose a single day or several — whatever fits your week.',
|
||||||
|
placement: 'below',
|
||||||
|
next: 'schedule-days-deadline',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: '.day-chips',
|
||||||
|
},
|
||||||
|
'schedule-days-deadline': {
|
||||||
|
id: 'schedule-days-deadline',
|
||||||
|
title: 'Set a deadline',
|
||||||
|
body: 'This is the time your child needs to finish by. Tap "Clear" to remove the deadline — then it can be done anytime that day.',
|
||||||
|
placement: 'auto',
|
||||||
|
next: 'schedule-days-exception',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: '.default-deadline-row',
|
||||||
|
},
|
||||||
|
'schedule-days-exception': {
|
||||||
|
id: 'schedule-days-exception',
|
||||||
|
title: 'Different time for one day?',
|
||||||
|
body: 'If one day needs a different deadline, tap "Set different time" for that day. Useful for early-dismissal days or busy afternoons.',
|
||||||
|
placement: 'auto',
|
||||||
|
next: 'schedule-enable-toggle',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: '.exception-row',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Schedule modal — Every X Days chain ──────────────────────────────────
|
||||||
|
'schedule-interval-frequency': {
|
||||||
|
id: 'schedule-interval-frequency',
|
||||||
|
title: 'How often?',
|
||||||
|
body: 'Set how many days pass between each time this chore or routine appears. Every 1 day means daily; every 7 days means weekly.',
|
||||||
|
placement: 'below',
|
||||||
|
next: 'schedule-interval-start',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: '.stepper',
|
||||||
|
},
|
||||||
|
'schedule-interval-start': {
|
||||||
|
id: 'schedule-interval-start',
|
||||||
|
title: 'Start date',
|
||||||
|
body: 'Pick the first day this chore or routine is expected. The schedule counts forward from here.',
|
||||||
|
placement: 'below',
|
||||||
|
next: 'schedule-interval-deadline',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: '[data-tutorial="schedule-interval-start"]',
|
||||||
|
},
|
||||||
|
'schedule-interval-deadline': {
|
||||||
|
id: 'schedule-interval-deadline',
|
||||||
|
title: 'Set a deadline',
|
||||||
|
body: 'This is the time your child needs to finish by. Tap "Clear" to remove the deadline — then it can be done anytime that day.',
|
||||||
|
placement: 'auto',
|
||||||
|
next: 'schedule-enable-toggle',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: '.interval-time-row',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Schedule modal — shared final step ───────────────────────────────────
|
||||||
|
'schedule-enable-toggle': {
|
||||||
|
id: 'schedule-enable-toggle',
|
||||||
|
title: 'Enable or pause',
|
||||||
|
body: 'Toggle this switch to turn the schedule on or off. When paused, the chore or routine is expected every day with no schedule restrictions.',
|
||||||
|
placement: 'auto',
|
||||||
|
anchorSelector: '.schedule-toggle-row',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── List-view help (triggered via ? button) ──────────────────────────────
|
||||||
|
'list-chore-help': {
|
||||||
|
id: 'list-chore-help',
|
||||||
|
title: 'Create your chore',
|
||||||
|
body: 'Tap the + button to add a new chore. Give it a name, choose points, and pick a picture so your child can spot it easily.',
|
||||||
|
placement: 'above',
|
||||||
|
next: 'list-edit-hint',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: '.fab',
|
||||||
|
},
|
||||||
|
'list-kindness-help': {
|
||||||
|
id: 'list-kindness-help',
|
||||||
|
title: 'Create your kindness act',
|
||||||
|
body: 'Tap the + button to add a new kindness act. Give it a name, choose points, and pick a picture so your child can spot it easily.',
|
||||||
|
placement: 'above',
|
||||||
|
next: 'list-edit-hint',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: '.fab',
|
||||||
|
},
|
||||||
|
'list-penalty-help': {
|
||||||
|
id: 'list-penalty-help',
|
||||||
|
title: 'Create your penalty',
|
||||||
|
body: 'Tap the + button to add a new penalty. Give it a name, choose points, and pick a picture so your child can spot it easily.',
|
||||||
|
placement: 'above',
|
||||||
|
next: 'list-edit-hint',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: '.fab',
|
||||||
|
},
|
||||||
|
'list-reward-help': {
|
||||||
|
id: 'list-reward-help',
|
||||||
|
title: 'Create your reward',
|
||||||
|
body: 'Tap the + button to add a new reward. Give it a name, choose a cost, and pick a picture so your child can spot it easily.',
|
||||||
|
placement: 'above',
|
||||||
|
next: 'list-edit-hint',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: '.fab',
|
||||||
|
},
|
||||||
|
'list-routine-help': {
|
||||||
|
id: 'list-routine-help',
|
||||||
|
title: 'Create your routine',
|
||||||
|
body: 'Tap the + button to add a new routine. Give it a name, choose points, and pick a picture so your child can spot it easily.',
|
||||||
|
placement: 'above',
|
||||||
|
next: 'list-edit-hint',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: '.fab',
|
||||||
|
},
|
||||||
|
'list-edit-hint': {
|
||||||
|
id: 'list-edit-hint',
|
||||||
|
title: 'Edit items',
|
||||||
|
body: 'Tap any item in the list to open it and make changes — rename it, adjust points, or swap the picture.',
|
||||||
|
placement: 'below',
|
||||||
|
next: 'list-delete-hint',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: '.list-item, .routine-item',
|
||||||
|
},
|
||||||
|
|
||||||
|
'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',
|
||||||
|
},
|
||||||
|
'edit-kindness-name': {
|
||||||
|
id: 'edit-kindness-name',
|
||||||
|
title: 'Kindness Act Name',
|
||||||
|
body: 'Give it a clear name your child will understand — like "Share your toys" or "Help a friend."',
|
||||||
|
placement: 'below',
|
||||||
|
next: 'edit-kindness-points',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: 'input#name',
|
||||||
|
},
|
||||||
|
'edit-kindness-points': {
|
||||||
|
id: 'edit-kindness-points',
|
||||||
|
title: 'Points',
|
||||||
|
body: 'How many points is this kindness worth? These work just like chore points — your child saves them up for rewards.',
|
||||||
|
placement: 'below',
|
||||||
|
next: 'edit-kindness-image',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: 'input#points',
|
||||||
|
},
|
||||||
|
'edit-kindness-image': {
|
||||||
|
id: 'edit-kindness-image',
|
||||||
|
title: 'Pick a Picture',
|
||||||
|
body: 'Choose a fun image so your child can spot this kindness act easily. Tap + or the camera to add one.',
|
||||||
|
placement: 'above',
|
||||||
|
anchorSelector: '.picker',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Penalty form chain ───────────────────────────────────────────────────
|
||||||
|
'edit-penalty-name': {
|
||||||
|
id: 'edit-penalty-name',
|
||||||
|
title: 'Penalty Name',
|
||||||
|
body: 'Give the penalty a clear name your child will understand — like "No screen time" or "Early bedtime."',
|
||||||
|
placement: 'below',
|
||||||
|
next: 'edit-penalty-points',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: 'input#name',
|
||||||
|
},
|
||||||
|
'edit-penalty-points': {
|
||||||
|
id: 'edit-penalty-points',
|
||||||
|
title: 'Points',
|
||||||
|
body: 'How many points does this penalty cost? When the rule is broken, these points are taken away.',
|
||||||
|
placement: 'below',
|
||||||
|
next: 'edit-penalty-image',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: 'input#points',
|
||||||
|
},
|
||||||
|
'edit-penalty-image': {
|
||||||
|
id: 'edit-penalty-image',
|
||||||
|
title: 'Pick a Picture',
|
||||||
|
body: 'Choose an image so your child can spot this penalty easily. Tap + or the camera to add one.',
|
||||||
|
placement: 'above',
|
||||||
|
anchorSelector: '.picker',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Reward form chain ────────────────────────────────────────────────────
|
||||||
|
'edit-reward-name': {
|
||||||
|
id: 'edit-reward-name',
|
||||||
|
title: 'Reward Name',
|
||||||
|
body: 'Give the reward a name your child will get excited about — like "Movie night" or "Ice cream treat."',
|
||||||
|
placement: 'below',
|
||||||
|
next: 'edit-reward-description',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: 'input#name',
|
||||||
|
},
|
||||||
|
'edit-reward-description': {
|
||||||
|
id: 'edit-reward-description',
|
||||||
|
title: 'Description',
|
||||||
|
body: 'Add a quick description so you remember the details — like "Pick the movie together" or "Any flavor they want."',
|
||||||
|
placement: 'below',
|
||||||
|
next: 'edit-reward-cost',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: 'input#description',
|
||||||
|
},
|
||||||
|
'edit-reward-cost': {
|
||||||
|
id: 'edit-reward-cost',
|
||||||
|
title: 'Cost',
|
||||||
|
body: 'How many points does this reward cost? Make it feel achievable — small rewards can cost less, big ones more.',
|
||||||
|
placement: 'below',
|
||||||
|
next: 'edit-reward-image',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: 'input#cost',
|
||||||
|
},
|
||||||
|
'edit-reward-image': {
|
||||||
|
id: 'edit-reward-image',
|
||||||
|
title: 'Pick a Picture',
|
||||||
|
body: 'Choose a fun image so your child can spot this reward easily. Tap + or the camera to add one.',
|
||||||
|
placement: 'above',
|
||||||
|
anchorSelector: '.picker',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Child form chain ─────────────────────────────────────────────────────
|
||||||
|
'edit-child-name': {
|
||||||
|
id: 'edit-child-name',
|
||||||
|
title: "Child's Name",
|
||||||
|
body: "Enter your child's name or nickname — whatever they like to be called.",
|
||||||
|
placement: 'below',
|
||||||
|
next: 'edit-child-age',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: 'input#name',
|
||||||
|
},
|
||||||
|
'edit-child-age': {
|
||||||
|
id: 'edit-child-age',
|
||||||
|
title: 'Age',
|
||||||
|
body: 'How old is your child? This helps tailor the experience to their level.',
|
||||||
|
placement: 'below',
|
||||||
|
next: 'edit-child-image',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: 'input#age',
|
||||||
|
},
|
||||||
|
'edit-child-image': {
|
||||||
|
id: 'edit-child-image',
|
||||||
|
title: 'Pick a Picture',
|
||||||
|
body: 'Choose a photo or avatar so your child can recognize their profile. Tap + or the camera to add one.',
|
||||||
|
placement: 'above',
|
||||||
|
anchorSelector: '.picker',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Routine form chain ───────────────────────────────────────────────────
|
||||||
|
'edit-routine-name': {
|
||||||
|
id: 'edit-routine-name',
|
||||||
|
title: 'Routine Name',
|
||||||
|
body: 'Give the routine a name your child will understand — like "Morning routine" or "Bedtime checklist."',
|
||||||
|
placement: 'below',
|
||||||
|
next: 'edit-routine-points',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: 'input#name',
|
||||||
|
},
|
||||||
|
'edit-routine-points': {
|
||||||
|
id: 'edit-routine-points',
|
||||||
|
title: 'Points',
|
||||||
|
body: 'How many points is the whole routine worth? Your child earns these when they finish every step.',
|
||||||
|
placement: 'below',
|
||||||
|
next: 'edit-routine-image',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: 'input#points',
|
||||||
|
},
|
||||||
|
'edit-routine-image': {
|
||||||
|
id: 'edit-routine-image',
|
||||||
|
title: 'Pick a Picture',
|
||||||
|
body: 'Choose an image so your child can spot this routine easily. Tap + or the camera to add one.',
|
||||||
|
placement: 'above',
|
||||||
|
next: 'create-routine-add-task',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: '.picker',
|
||||||
|
},
|
||||||
|
|
||||||
|
'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',
|
||||||
|
next: 'routine-task-reorder',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: '.add-task-trigger',
|
||||||
|
},
|
||||||
|
'routine-task-reorder': {
|
||||||
|
id: 'routine-task-reorder',
|
||||||
|
title: 'Reorder tasks',
|
||||||
|
body: 'Drag the handle up or down to change the order your child sees the tasks in.',
|
||||||
|
placement: 'auto',
|
||||||
|
next: 'routine-task-edit',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: '[data-tutorial="routine-task-reorder"]',
|
||||||
|
},
|
||||||
|
'routine-task-edit': {
|
||||||
|
id: 'routine-task-edit',
|
||||||
|
title: 'Edit a task',
|
||||||
|
body: 'Tap Edit to change a task name or picture. Useful for fixing typos or updating the look.',
|
||||||
|
placement: 'auto',
|
||||||
|
next: 'routine-task-delete',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: '[data-tutorial="routine-task-edit"]',
|
||||||
|
},
|
||||||
|
'routine-task-delete': {
|
||||||
|
id: 'routine-task-delete',
|
||||||
|
title: 'Delete a task',
|
||||||
|
body: 'Tap Delete to remove a task from the routine. This does not delete the routine itself — just this step.',
|
||||||
|
placement: 'auto',
|
||||||
|
anchorSelector: '[data-tutorial="routine-task-delete"]',
|
||||||
|
},
|
||||||
|
'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-kindness',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: '.assign-buttons button:nth-child(1)',
|
||||||
|
},
|
||||||
|
'assign-kindness': {
|
||||||
|
id: 'assign-kindness',
|
||||||
|
title: 'Assign kindness acts',
|
||||||
|
body: 'Kindness acts reward thoughtful behavior — sharing, helping, saying please. Tap to pick which ones this child can do.',
|
||||||
|
placement: 'above',
|
||||||
|
next: 'assign-reward',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: '.assign-buttons button:nth-child(4)',
|
||||||
|
},
|
||||||
|
'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',
|
||||||
|
next: 'assign-penalty',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: '.assign-buttons button:nth-child(2)',
|
||||||
|
},
|
||||||
|
'assign-penalty': {
|
||||||
|
id: 'assign-penalty',
|
||||||
|
title: 'Assign penalties',
|
||||||
|
body: 'Penalties subtract points when rules are broken. Use them sparingly — they work best alongside lots of positive tasks.',
|
||||||
|
placement: 'above',
|
||||||
|
next: 'item-kebab-overview',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: '.assign-buttons button:nth-child(5)',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Generic kebab overview (entity-type agnostic) ────────────────────────
|
||||||
|
'item-kebab-overview': {
|
||||||
|
id: 'item-kebab-overview',
|
||||||
|
title: 'Quick actions',
|
||||||
|
body: 'Each item has a menu with quick actions. Tap the three dots on any item to edit points, change schedules, extend deadlines, or reset.',
|
||||||
|
placement: 'auto',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Assignment list hints ────────────────────────────────────────────────
|
||||||
|
'assign-chore-list': {
|
||||||
|
id: 'assign-chore-list',
|
||||||
|
title: 'Pick chores',
|
||||||
|
body: 'Tap any chore to check or uncheck it. Checked chores are assigned to this child and show up in their view.',
|
||||||
|
placement: 'below',
|
||||||
|
next: 'assign-submit',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: '.list-item, .routine-item',
|
||||||
|
},
|
||||||
|
'assign-kindness-list': {
|
||||||
|
id: 'assign-kindness-list',
|
||||||
|
title: 'Pick kindness acts',
|
||||||
|
body: 'Tap any kindness act to check or uncheck it. Checked acts are assigned to this child and show up in their view.',
|
||||||
|
placement: 'below',
|
||||||
|
next: 'assign-submit',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: '.list-item, .routine-item',
|
||||||
|
},
|
||||||
|
'assign-penalty-list': {
|
||||||
|
id: 'assign-penalty-list',
|
||||||
|
title: 'Pick penalties',
|
||||||
|
body: 'Tap any penalty to check or uncheck it. Checked penalties are assigned to this child and show up in their view.',
|
||||||
|
placement: 'below',
|
||||||
|
next: 'assign-submit',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: '.list-item, .routine-item',
|
||||||
|
},
|
||||||
|
'assign-reward-list': {
|
||||||
|
id: 'assign-reward-list',
|
||||||
|
title: 'Pick rewards',
|
||||||
|
body: 'Tap any reward to check or uncheck it. Checked rewards are assigned to this child and show up in their view.',
|
||||||
|
placement: 'below',
|
||||||
|
next: 'assign-submit',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: '.list-item, .routine-item',
|
||||||
|
},
|
||||||
|
'assign-routine-list': {
|
||||||
|
id: 'assign-routine-list',
|
||||||
|
title: 'Pick routines',
|
||||||
|
body: 'Tap any routine to check or uncheck it. Checked routines are assigned to this child and show up in their view.',
|
||||||
|
placement: 'below',
|
||||||
|
next: 'assign-submit',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: '.list-item, .routine-item',
|
||||||
|
},
|
||||||
|
'assign-submit': {
|
||||||
|
id: 'assign-submit',
|
||||||
|
title: 'Save your picks',
|
||||||
|
body: 'When you are happy with the selection, tap Submit to save. Tap Cancel to leave without changing anything.',
|
||||||
|
placement: 'above',
|
||||||
|
anchorSelector: '.actions .btn-primary',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── List management hints ────────────────────────────────────────────────
|
||||||
|
'list-delete-hint': {
|
||||||
|
id: 'list-delete-hint',
|
||||||
|
title: 'Delete items',
|
||||||
|
body: 'Tap the red X on any item you created to delete it. Deleting also removes it from any child it was assigned to.',
|
||||||
|
placement: 'auto',
|
||||||
|
anchorSelector: '.delete-btn',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Kebab menus ──────────────────────────────────────────────────────────
|
||||||
|
'child-kebab': {
|
||||||
|
id: 'child-kebab',
|
||||||
|
title: 'Quick actions',
|
||||||
|
body: 'Tap the three dots on any child card to open the menu. From here you can edit the child\'s name or photo, clear their points back to zero, or remove them entirely.',
|
||||||
|
placement: 'below',
|
||||||
|
anchorSelector: '.kebab-btn',
|
||||||
|
},
|
||||||
|
'chore-kebab-overview': {
|
||||||
|
id: 'chore-kebab-overview',
|
||||||
|
title: 'Chore actions',
|
||||||
|
body: 'Tap the three dots on any chore for quick actions. You can edit points, change the schedule, extend the deadline when a chore is late, or reset a completed chore.',
|
||||||
|
placement: 'auto',
|
||||||
|
},
|
||||||
|
'chore-kebab-menu': {
|
||||||
|
id: 'chore-kebab-menu',
|
||||||
|
title: 'Chore actions',
|
||||||
|
body: 'Edit the points or change the schedule for this chore. Tap Next to see each option.',
|
||||||
|
placement: 'auto',
|
||||||
|
next: 'chore-edit-points',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: '.kebab-menu',
|
||||||
|
},
|
||||||
|
'chore-edit-points': {
|
||||||
|
id: 'chore-edit-points',
|
||||||
|
title: 'Edit points',
|
||||||
|
body: 'Change how many points this chore is worth. Higher points for harder chores.',
|
||||||
|
placement: 'auto',
|
||||||
|
next: 'chore-schedule',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: '[data-tutorial="chore-edit-points"]',
|
||||||
|
},
|
||||||
|
'chore-schedule': {
|
||||||
|
id: 'chore-schedule',
|
||||||
|
title: 'Change schedule',
|
||||||
|
body: 'Open the schedule to change which days this chore appears or adjust the deadline.',
|
||||||
|
placement: 'auto',
|
||||||
|
anchorSelector: '[data-tutorial="chore-schedule"]',
|
||||||
|
},
|
||||||
|
'kebab-edit-points-cost': {
|
||||||
|
id: 'kebab-edit-points-cost',
|
||||||
|
title: 'Change points or cost',
|
||||||
|
body: 'You can change how many points a chore, routine, reward, or other item is worth — or what a reward costs — at any time from this menu.',
|
||||||
|
placement: 'auto',
|
||||||
|
anchorSelector: '[data-tutorial="kebab-edit-points-cost"]',
|
||||||
|
},
|
||||||
|
'point-editor-help': {
|
||||||
|
id: 'point-editor-help',
|
||||||
|
title: 'Edit points or cost',
|
||||||
|
body: 'Enter a new value to change how many points this chore, routine, or reward is worth — or what this reward costs — just for this child.',
|
||||||
|
placement: 'auto',
|
||||||
|
anchorSelector: 'input#custom-value',
|
||||||
|
},
|
||||||
|
'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',
|
||||||
|
anchorSelector: '[data-tutorial="chore-extend-time"]',
|
||||||
|
},
|
||||||
|
'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',
|
||||||
|
anchorSelector: '[data-tutorial="chore-reset"]',
|
||||||
|
},
|
||||||
|
'routine-kebab-menu': {
|
||||||
|
id: 'routine-kebab-menu',
|
||||||
|
title: 'Routine actions',
|
||||||
|
body: 'Edit the routine, adjust points, or change the schedule. Tap Next to see each option.',
|
||||||
|
placement: 'auto',
|
||||||
|
next: 'routine-edit',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: '.kebab-menu',
|
||||||
|
},
|
||||||
|
'routine-edit': {
|
||||||
|
id: 'routine-edit',
|
||||||
|
title: 'Edit routine',
|
||||||
|
body: 'Open the routine to add, remove, or reorder the tasks inside it.',
|
||||||
|
placement: 'auto',
|
||||||
|
next: 'routine-edit-points',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: '[data-tutorial="routine-edit"]',
|
||||||
|
},
|
||||||
|
'routine-edit-points': {
|
||||||
|
id: 'routine-edit-points',
|
||||||
|
title: 'Edit points',
|
||||||
|
body: 'Change how many points the whole routine is worth. Your child earns these when they finish every step.',
|
||||||
|
placement: 'auto',
|
||||||
|
next: 'routine-schedule',
|
||||||
|
ctaLabel: 'Next',
|
||||||
|
anchorSelector: '[data-tutorial="routine-edit-points"]',
|
||||||
|
},
|
||||||
|
'routine-schedule': {
|
||||||
|
id: 'routine-schedule',
|
||||||
|
title: 'Change schedule',
|
||||||
|
body: 'Open the schedule to adjust when this routine appears.',
|
||||||
|
placement: 'auto',
|
||||||
|
anchorSelector: '[data-tutorial="routine-schedule"]',
|
||||||
|
},
|
||||||
|
'routine-extend-time': {
|
||||||
|
id: 'routine-extend-time',
|
||||||
|
title: 'Extend the deadline',
|
||||||
|
body: 'When a routine runs late, give your child more time for the rest of today — no need to reschedule.',
|
||||||
|
placement: 'auto',
|
||||||
|
anchorSelector: '[data-tutorial="routine-extend-time"]',
|
||||||
|
},
|
||||||
|
'routine-reset': {
|
||||||
|
id: 'routine-reset',
|
||||||
|
title: 'Reset',
|
||||||
|
body: 'Already done today? Reset lets your child do the routine again today.',
|
||||||
|
placement: 'auto',
|
||||||
|
anchorSelector: '[data-tutorial="routine-reset"]',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 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',
|
||||||
|
},
|
||||||
|
}
|
||||||
196
plan-tutorial.md
Normal file
196
plan-tutorial.md
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
# Tutorial / Onboarding Mode
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
New parents signing up for the chore app land on an empty screen with no guidance. Many will not be especially technical — a busy parent setting things up on a phone needs to be shown where to tap, what each control does, and what states like `TOO LATE` / `PENDING` mean, without feeling badgered. Today there is no onboarding, no first-time tips, no contextual help.
|
||||||
|
|
||||||
|
The goal is a modular tutorial layer that:
|
||||||
|
- Walks users through the essentials (PIN → child → chore) so the app is never empty after first use.
|
||||||
|
- Surfaces contextual hints **just-in-time** when users reach a feature for the first time (image picker, kebab menus, scheduler, status badges, notifications, assignment, etc.).
|
||||||
|
- Persists progress per-user on the backend so it syncs across devices and is dismissed only once.
|
||||||
|
- Can be reset or disabled from the profile page.
|
||||||
|
- Is friction-light: visible Skip, one card at a time outside the intro, a permanent `?` button so users can re-trigger help themselves.
|
||||||
|
- Is extensible — adding a tutorial for a future feature is roughly a one-line call + a registry entry.
|
||||||
|
|
||||||
|
## Approach (locked decisions)
|
||||||
|
|
||||||
|
- **Cadence**: brief forced 3-step intro after first sign-in (PIN setup → first child → first chore), then everything else is just-in-time.
|
||||||
|
- **Mobile**: bottom-sheet card on screens ≤600px, floating tooltip on desktop. Spotlight cut-out is shared.
|
||||||
|
- **Skip behaviour**: a single Skip suppresses all hints for the session; user re-engages via the per-screen `?` button or by resetting from the profile.
|
||||||
|
- **State storage**: `tutorial_progress: dict[str, bool]` and `tutorial_enabled: bool` on the `User` model, broadcast via existing `PROFILE_UPDATED` SSE event.
|
||||||
|
- **Step authoring**: imperative `tutorial.maybeShow('step-id', anchorRef)` from `onMounted` / `watch` in each host component, with step copy in a single registry file.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- **`backend/models/user.py`** — add `tutorial_enabled: bool = True` and `tutorial_progress: dict = field(default_factory=dict)` alongside `email_digest_enabled`. Update `from_dict` / `to_dict`.
|
||||||
|
- **`backend/api/user_api.py`** — add `PATCH /user/tutorial-progress` that accepts:
|
||||||
|
- `{ "step_id": "...", "seen": true }` — mark one step
|
||||||
|
- `{ "reset": true }` — clear the dict
|
||||||
|
- `{ "enabled": false }` — toggle
|
||||||
|
Each branch emits `EventType.PROFILE_UPDATED` (already wired) so other tabs / devices stay in sync.
|
||||||
|
|
||||||
|
### Frontend — new directory `frontend/src/tutorial/`
|
||||||
|
|
||||||
|
- **`controller.ts`** — module-level `ref()`s (matches the no-Pinia pattern from `stores/auth.ts`):
|
||||||
|
```ts
|
||||||
|
export const tutorialEnabled = ref(true)
|
||||||
|
export const tutorialProgress = ref<Record<string, boolean>>({})
|
||||||
|
export const activeStep = ref<ActiveStep | null>(null)
|
||||||
|
|
||||||
|
export function shouldShowStep(id: string): boolean
|
||||||
|
export function maybeShow(id: string, anchor: HTMLElement | (() => HTMLElement | null)): void
|
||||||
|
export async function markStepSeen(id: string): Promise<void> // PATCH + optimistic
|
||||||
|
export async function resetAllProgress(): Promise<void>
|
||||||
|
export async function setTutorialEnabled(on: boolean): Promise<void>
|
||||||
|
```
|
||||||
|
Hydrated from `/user/profile` (extend the existing fetch in `LoginButton.vue`); re-hydrated on `profile_updated` SSE.
|
||||||
|
|
||||||
|
- **`steps.ts`** — single registry of step copy/config:
|
||||||
|
```ts
|
||||||
|
export interface StepDef {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
next?: string // chains a tour
|
||||||
|
placement?: 'auto' | 'below' | 'above'
|
||||||
|
ctaLabel?: string // defaults "Got it"
|
||||||
|
}
|
||||||
|
export const stepRegistry: Record<string, StepDef> = { … }
|
||||||
|
```
|
||||||
|
Step IDs follow flat kebab-case: `setup-parent-pin`, `create-child`, `create-chore`, `create-chore-image-photo`, `create-chore-image-camera`, `create-chore-schedule`, `chore-kebab`, `chore-kebab-extend-time`, `status-too-late`, `status-pending`, `notification-click`, `assign-chore`, `child-mode-tour-offer`, etc.
|
||||||
|
|
||||||
|
- **`TutorialOverlay.vue`** — mounted once in `App.vue`. Watches `activeStep`. Renders:
|
||||||
|
- Four-rectangle dim (top/left/right/bottom of the anchor's bbox) with `rgba(0,0,0,0.55)`.
|
||||||
|
- A coach-mark card with `title`, `body`, **Skip tour** + primary action button.
|
||||||
|
- Desktop: tooltip positioned by `getBoundingClientRect()` + viewport clamping (no `floating-ui` — same hand-rolled pattern as the existing `TimePickerPopover.vue`).
|
||||||
|
- Mobile (`window.matchMedia('(max-width: 600px)')`): card docks as a bottom sheet with a chevron pointing toward the spotlight.
|
||||||
|
- Re-positions on `resize`, capture-phase `scroll`, and `ResizeObserver` on the card.
|
||||||
|
- `role="dialog"`, focus trap, Esc to dismiss, `aria-live="polite"` for announcements, `prefers-reduced-motion` kills animations.
|
||||||
|
|
||||||
|
- **`HelpButton.vue`** — a small `?` icon placed in the `ParentLayout` topbar (and `ChildLayout`). On tap, re-fires the most relevant step for the current route from a `routeToStep` map. This is the user's escape hatch — critical for the non-technical audience.
|
||||||
|
|
||||||
|
### Triggering — three sources, all routed through `maybeShow`
|
||||||
|
|
||||||
|
1. **Route entered** — `router.afterEach` in `controller.ts` consults a small `routeTriggers: Record<RouteName, string>` map (used for `tab-tasks`, `tab-rewards`, `tab-notifications`, …).
|
||||||
|
2. **Element first rendered** — host component calls `maybeShow` in `onMounted` or `watchEffect` once its anchor `ref` is bound.
|
||||||
|
3. **State-derived** — host component `watch`es a reactive condition (`isChoreLate`, `menuOpen`, `firstPendingChore`) and calls `maybeShow` when it flips true. This is how dynamic kebab options (`Extend Time` only on late chores) and badge-first-appearance (`TOO LATE`, `PENDING`) get triggered.
|
||||||
|
|
||||||
|
No global DOM scanning, no MutationObserver — every anchor is already owned by a Vue component that knows when it appears.
|
||||||
|
|
||||||
|
## Step coverage (the JIT hints)
|
||||||
|
|
||||||
|
Phase 2 wires these via `maybeShow` calls in the listed components. All copy is warm/parent-friendly, never jargon.
|
||||||
|
|
||||||
|
| Step ID | Trigger | Anchor |
|
||||||
|
|---|---|---|
|
||||||
|
| `setup-parent-pin` (intro #1) | First load after signup, no PIN set | `LoginButton.vue` avatar |
|
||||||
|
| `create-child` (intro #2) | After PIN set, 0 children | FAB in `ChildrenListView.vue` |
|
||||||
|
| `create-chore` (intro #3) | After 1+ child, 0 chores, on Tasks tab | FAB in tasks view |
|
||||||
|
| `create-chore-image-photo` | First time `ImagePicker` opens | `+` button (`addPhotoBtn` ref) in `ImagePicker.vue` |
|
||||||
|
| `create-chore-image-camera` | First time `ImagePicker` opens | Camera button (`cameraBtn` ref) in `ImagePicker.vue` |
|
||||||
|
| `create-chore-schedule` | First time `ScheduleModal` opens | day chips in `ScheduleModal.vue` |
|
||||||
|
| `create-kindness`, `create-penalty`, `create-routine` | First time landing on each create view | FAB / form |
|
||||||
|
| `create-routine-add-task` | First time inside `RoutineEditView` | `.add-task-trigger` |
|
||||||
|
| `create-reward` | First time on Rewards tab with 0 rewards | FAB |
|
||||||
|
| `notification-click` | First time on Notifications tab with 1+ notification | first notification row in `NotificationView.vue` |
|
||||||
|
| `select-child` then `assign-chore` / `assign-reward` / `assign-routine` | First time entering ParentView for a specific child | the assignment sections |
|
||||||
|
| `child-kebab` | First time `.kebab-btn` opens in `ChildrenListView.vue` | the open menu |
|
||||||
|
| `chore-kebab` | First time `.kebab-menu` opens in `ParentView.vue` | the open menu |
|
||||||
|
| `chore-kebab-extend-time` | First time the `Extend Time` menu item renders (chore is late) | that menu item |
|
||||||
|
| `chore-kebab-reset` | First time the `Reset` menu item renders | that menu item |
|
||||||
|
| `status-too-late` | First time `.chore-stamp` (TOO LATE) renders for any chore | the badge |
|
||||||
|
| `status-pending` | First time `.chore-stamp.pending-stamp` renders | the badge |
|
||||||
|
|
||||||
|
Adding a future step = one entry in `steps.ts` + one `maybeShow` call at the anchor site.
|
||||||
|
|
||||||
|
## Profile controls (`UserProfile.vue`)
|
||||||
|
|
||||||
|
Add a new "Help" section after the existing email-digest / push-notifications toggles:
|
||||||
|
- **Show tutorial tips** toggle — bound to `tutorialEnabled`, copy: *"Show helpful tips as I use the app."*
|
||||||
|
- **Restart tutorial** button — opens a `ModalDialog` confirm (*"Start the tour again from the beginning?"*), then calls `resetAllProgress()` and routes to `/parent` (a toast confirms reset).
|
||||||
|
|
||||||
|
Dev bypass: support `?tutorial=off` / `?tutorial=reset` in `controller.ts` init (gated by `import.meta.env.DEV`) for debugging without touching the backend.
|
||||||
|
|
||||||
|
## Child-mode tour (parent learns to teach)
|
||||||
|
|
||||||
|
A derived `watchEffect` in `controller.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
watchEffect(() => {
|
||||||
|
const p = tutorialProgress.value
|
||||||
|
if (p['create-child'] && p['create-chore'] && p['create-reward']
|
||||||
|
&& !p['child-mode-tour-offer']) {
|
||||||
|
showOfferModal() // ModalDialog with Yes / Maybe later / No thanks
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Yes** → mark `child-mode-tour-offer` seen, navigate to `/child`, fire `child-mode-*` steps (separate IDs, same registry/controller).
|
||||||
|
- **Maybe later** → don't mark seen; reappears once next session, then auto-converts to "No thanks" (max two asks).
|
||||||
|
- **No thanks** → mark seen, never asked again (reset clears it).
|
||||||
|
|
||||||
|
## UX rules baked in (for non-technical users)
|
||||||
|
|
||||||
|
- 3-step forced intro, never longer. Single-card JIT hints elsewhere — no chained `Next → Next → Next` outside the intro.
|
||||||
|
- Skip is always visible, labeled "Skip tour", and one Skip suppresses everything for the session.
|
||||||
|
- Persistent `?` button on every screen — taps re-fire the most relevant step. This is the single most important affordance for users who don't remember the first run.
|
||||||
|
- Copy is short and warm: *"Let's add your first kiddo"*, not *"Create a child entity."*
|
||||||
|
- If an anchor element doesn't exist (e.g. 0 chores so no kebab to point at), the step silently waits — never an arrow into empty space.
|
||||||
|
- Bottom-sheet on mobile keeps text at thumb height and survives the on-screen keyboard via the `visualViewport` API.
|
||||||
|
- `prefers-reduced-motion`, focus trap, Esc-to-dismiss, ≥5:1 contrast on the highlight ring.
|
||||||
|
|
||||||
|
## Phasing
|
||||||
|
|
||||||
|
**Phase 1 — Infrastructure + one end-to-end step.**
|
||||||
|
- Backend `tutorial_enabled` / `tutorial_progress` fields, PATCH endpoint, SSE wiring.
|
||||||
|
- Frontend `controller.ts`, `steps.ts` (one entry), `TutorialOverlay.vue` mounted in `App.vue`, hydrate from profile fetch, listen for `profile_updated`.
|
||||||
|
- Ship `create-child` end-to-end (triggered from `ChildrenListView.vue` when child list is empty).
|
||||||
|
- Manual verification: sign up a fresh user, see the coach mark, dismiss, confirm it doesn't return; reload to confirm persistence; open in second tab to confirm SSE sync.
|
||||||
|
|
||||||
|
**Phase 2 — Roll out remaining steps.**
|
||||||
|
- Add the 3-step forced intro chaining (`setup-parent-pin` → `create-child` → `create-chore`).
|
||||||
|
- Add the JIT step entries from the coverage table; wire `maybeShow` calls at each anchor.
|
||||||
|
- Add `HelpButton.vue` in both layouts with a `routeToStep` map.
|
||||||
|
|
||||||
|
**Phase 3 — Profile controls + child-mode tour.**
|
||||||
|
- "Help" section in `UserProfile.vue` (toggle + restart button).
|
||||||
|
- `child-mode-tour-offer` watcher + child-mode step set.
|
||||||
|
- Dev bypass URL params.
|
||||||
|
|
||||||
|
## Critical files
|
||||||
|
|
||||||
|
**Backend**
|
||||||
|
- `backend/models/user.py` — model fields
|
||||||
|
- `backend/api/user_api.py` — PATCH endpoint
|
||||||
|
- (no event type changes — `PROFILE_UPDATED` is reused)
|
||||||
|
|
||||||
|
**Frontend (new)**
|
||||||
|
- `frontend/src/tutorial/controller.ts`
|
||||||
|
- `frontend/src/tutorial/steps.ts`
|
||||||
|
- `frontend/src/tutorial/TutorialOverlay.vue`
|
||||||
|
- `frontend/src/tutorial/HelpButton.vue`
|
||||||
|
|
||||||
|
**Frontend (modified — `maybeShow` call sites, mostly one-liners)**
|
||||||
|
- `frontend/src/App.vue` — mount `TutorialOverlay`
|
||||||
|
- `frontend/src/common/models.ts` — `User` interface additions
|
||||||
|
- `frontend/src/components/shared/LoginButton.vue` — hydrate controller from profile fetch
|
||||||
|
- `frontend/src/layout/ParentLayout.vue`, `frontend/src/layout/ChildLayout.vue` — `HelpButton`
|
||||||
|
- `frontend/src/components/shared/ChildrenListView.vue` — `create-child`, `child-kebab`
|
||||||
|
- `frontend/src/components/child/ParentView.vue` — `chore-kebab`, `chore-kebab-extend-time`, `status-too-late`, `status-pending`, `assign-*`, `select-child`
|
||||||
|
- `frontend/src/components/task/ChoreEditView.vue` + sibling create views — `create-chore`, `create-kindness`, `create-penalty`
|
||||||
|
- `frontend/src/components/utils/ImagePicker.vue` — `create-chore-image-photo`, `create-chore-image-camera`
|
||||||
|
- `frontend/src/components/routine/RoutineEditView.vue` — `create-routine`, `create-routine-add-task`
|
||||||
|
- `frontend/src/components/reward/RewardEditView.vue` — `create-reward`
|
||||||
|
- `frontend/src/components/notification/NotificationView.vue` — `notification-click`
|
||||||
|
- `frontend/src/components/shared/ScheduleModal.vue` — `create-chore-schedule`
|
||||||
|
- `frontend/src/components/profile/UserProfile.vue` — "Help" section
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- **Backend**: `pytest tests/test_user_api.py` covering the new PATCH branches (mark / reset / disable), plus a check that `PROFILE_UPDATED` is emitted.
|
||||||
|
- **Frontend unit**: `npm run test:unit` for `controller.ts` (`shouldShowStep`, `markStepSeen` optimism, reset/disable).
|
||||||
|
- **E2E** (`npx playwright test`): a new bucket `chromium-tutorial` with an isolated user that signs up fresh, walks the 3-step intro, navigates to Rewards/Notifications to hit JIT hints, then resets from the profile and confirms the intro replays.
|
||||||
|
- **Manual mobile check**: Chrome devtools at 375×667, confirm the bottom-sheet variant renders, survives the on-screen keyboard, and re-positions on rotation.
|
||||||
|
- **Manual cross-device SSE**: log in to two tabs, dismiss a step in one, confirm the second tab updates without manual refresh.
|
||||||
|
- **Accessibility**: keyboard-only run-through (Tab/Esc/Enter); VoiceOver pass on macOS to confirm `aria-live` announcements; `prefers-reduced-motion` set in devtools to confirm pulse animation is suppressed.
|
||||||
Reference in New Issue
Block a user