feat: add push notification settings to user profile and update related functionality
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m50s

This commit is contained in:
2026-04-20 10:43:09 -04:00
parent fd28c89cbf
commit b529ddaa02
8 changed files with 249 additions and 191 deletions

View File

@@ -10,7 +10,7 @@ from api.pending_confirmation import PendingConfirmationResponse
from api.reward_status import RewardStatus from api.reward_status import RewardStatus
from api.utils import send_event_for_current_user, get_validated_user_id from api.utils import send_event_for_current_user, get_validated_user_id
import api.child_action_helpers as chore_actions import api.child_action_helpers as chore_actions
from db.db import child_db, task_db, reward_db, pending_reward_db, pending_confirmations_db from db.db import child_db, task_db, reward_db, pending_reward_db, pending_confirmations_db, users_db
from db.tracking import insert_tracking_event from db.tracking import insert_tracking_event
from db.child_overrides import get_override, delete_override, delete_overrides_for_child from db.child_overrides import get_override, delete_override, delete_overrides_for_child
from events.types.child_chore_confirmation import ChildChoreConfirmation from events.types.child_chore_confirmation import ChildChoreConfirmation
@@ -929,6 +929,8 @@ def request_reward(id):
send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward.id, ChildRewardRequest.REQUEST_CREATED))) send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward.id, ChildRewardRequest.REQUEST_CREATED)))
# Fire web push notification to all parent subscriptions # Fire web push notification to all parent subscriptions
_push_user = users_db.get(Query().id == user_id)
if _push_user and _push_user.get('push_notifications_enabled', True):
try: try:
approve_token = create_action_token(user_id, child.id, reward.id, 'reward', 'approve') approve_token = create_action_token(user_id, child.id, reward.id, 'reward', 'approve')
deny_token = create_action_token(user_id, child.id, reward.id, 'reward', 'deny') deny_token = create_action_token(user_id, child.id, reward.id, 'reward', 'deny')
@@ -1139,6 +1141,8 @@ def confirm_chore(id):
ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_CONFIRMED))) ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_CONFIRMED)))
# Fire web push notification to all parent subscriptions # Fire web push notification to all parent subscriptions
_push_user = users_db.get(Query().id == user_id)
if _push_user and _push_user.get('push_notifications_enabled', True):
try: try:
approve_token = create_action_token(user_id, id, task_id, 'chore', 'approve') approve_token = create_action_token(user_id, id, task_id, 'chore', 'approve')
deny_token = create_action_token(user_id, id, task_id, 'chore', 'deny') deny_token = create_action_token(user_id, id, task_id, 'chore', 'deny')

View File

@@ -48,6 +48,7 @@ def get_profile():
'email': user.email, 'email': user.email,
'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,
}), 200 }), 200
@user_api.route('/user/profile', methods=['PUT']) @user_api.route('/user/profile', methods=['PUT'])
@@ -59,11 +60,12 @@ def update_profile():
if not user: if not user:
return jsonify({'error': 'Unauthorized'}), 401 return jsonify({'error': 'Unauthorized'}), 401
data = request.get_json() data = request.get_json()
# Only allow first_name, last_name, image_id, email_digest_enabled to be updated # Only allow first_name, last_name, image_id, email_digest_enabled, push_notifications_enabled to be updated
first_name = data.get('first_name') first_name = data.get('first_name')
last_name = data.get('last_name') last_name = data.get('last_name')
image_id = data.get('image_id') image_id = data.get('image_id')
email_digest_enabled = data.get('email_digest_enabled') email_digest_enabled = data.get('email_digest_enabled')
push_notifications_enabled = data.get('push_notifications_enabled')
if first_name is not None: if first_name is not None:
user.first_name = first_name user.first_name = first_name
if last_name is not None: if last_name is not None:
@@ -72,6 +74,8 @@ def update_profile():
user.image_id = image_id user.image_id = image_id
if email_digest_enabled is not None: if email_digest_enabled is not None:
user.email_digest_enabled = bool(email_digest_enabled) user.email_digest_enabled = bool(email_digest_enabled)
if push_notifications_enabled is not None:
user.push_notifications_enabled = bool(push_notifications_enabled)
users_db.update(user.to_dict(), UserQuery.email == user.email) users_db.update(user.to_dict(), UserQuery.email == user.email)
# Create tracking event # Create tracking event
@@ -84,6 +88,8 @@ def update_profile():
metadata['image_updated'] = True metadata['image_updated'] = True
if email_digest_enabled is not None: if email_digest_enabled is not None:
metadata['email_digest_enabled_updated'] = True metadata['email_digest_enabled_updated'] = True
if push_notifications_enabled is not None:
metadata['push_notifications_enabled_updated'] = True
tracking_event = TrackingEvent.create_event( tracking_event = TrackingEvent.create_event(
user_id=user_id, user_id=user_id,

View File

@@ -24,6 +24,7 @@ class User(BaseModel):
token_version: int = 0 token_version: int = 0
timezone: str | None = None timezone: str | None = None
email_digest_enabled: bool = True email_digest_enabled: bool = True
push_notifications_enabled: bool = True
@classmethod @classmethod
def from_dict(cls, d: dict): def from_dict(cls, d: dict):
@@ -49,6 +50,7 @@ class User(BaseModel):
token_version=d.get('token_version', 0), token_version=d.get('token_version', 0),
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),
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')
@@ -79,5 +81,6 @@ class User(BaseModel):
'token_version': self.token_version, 'token_version': self.token_version,
'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,
}) })
return base return base

View File

@@ -558,7 +558,7 @@ describe('UserProfile - Profile Update', () => {
}) })
}) })
describe('UserProfile - Email Digest Toggle', () => { describe('UserProfile - Notification Toggles', () => {
let wrapper: VueWrapper<any> let wrapper: VueWrapper<any>
function mountWithDigest(emailDigestEnabled: boolean) { function mountWithDigest(emailDigestEnabled: boolean) {
@@ -592,85 +592,97 @@ describe('UserProfile - Email Digest Toggle', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
;(global.fetch as any).mockClear() ;(global.fetch as any).mockClear()
// Default response for any extra fetch calls (e.g. PUT)
;(global.fetch as any).mockResolvedValue({ ;(global.fetch as any).mockResolvedValue({
ok: true, ok: true,
json: async () => ({}), json: async () => ({}),
}) })
}) })
it('renders the email digest toggle button', async () => { it('initializes email_digest_enabled to true in initialData when profile returns true', async () => {
wrapper = mountWithDigest(true) wrapper = mountWithDigest(true)
await flushPromises() await flushPromises()
await nextTick() await nextTick()
expect(wrapper.find('.toggle-btn').exists()).toBe(true) expect(wrapper.vm.initialData.email_digest_enabled).toBe(true)
}) })
it('initializes toggle as enabled when email_digest_enabled is true', async () => { it('initializes email_digest_enabled to false in initialData when profile returns false', async () => {
wrapper = mountWithDigest(true)
await flushPromises()
await nextTick()
expect(wrapper.vm.emailDigestEnabled).toBe(true)
expect(wrapper.find('.toggle-btn').attributes('aria-pressed')).toBe('true')
})
it('initializes toggle as disabled when email_digest_enabled is false', async () => {
wrapper = mountWithDigest(false) wrapper = mountWithDigest(false)
await flushPromises() await flushPromises()
await nextTick() await nextTick()
expect(wrapper.vm.emailDigestEnabled).toBe(false) expect(wrapper.vm.initialData.email_digest_enabled).toBe(false)
expect(wrapper.find('.toggle-btn').attributes('aria-pressed')).toBe('false')
}) })
it('calls PUT /api/user/profile with email_digest_enabled: false when toggled off', async () => { it('initializes push_enabled to false in initialData when not subscribed', async () => {
wrapper = mountWithDigest(true)
await flushPromises()
await nextTick()
expect(wrapper.vm.initialData.push_enabled).toBe(false)
})
it('fields array includes email_digest_enabled as toggle type', async () => {
wrapper = mountWithDigest(true)
await flushPromises()
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) 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.toggleDigest() await wrapper.vm.handleSubmit({
image_id: null,
first_name: 'Test',
last_name: 'User',
email: 'test@example.com',
email_digest_enabled: false,
push_enabled: false,
})
await flushPromises() await flushPromises()
expect(global.fetch).toHaveBeenCalledWith( const putCall = (global.fetch as any).mock.calls.find((c: any[]) => c[1]?.method === 'PUT')
'/api/user/profile', expect(putCall).toBeDefined()
expect.objectContaining({ const body = JSON.parse(putCall[1].body)
method: 'PUT', expect(body.email_digest_enabled).toBe(false)
body: JSON.stringify({ email_digest_enabled: false }),
}),
)
}) })
it('calls PUT /api/user/profile with email_digest_enabled: true when toggled on', async () => { it('profile PUT omits email_digest_enabled when unchanged on submit', async () => {
wrapper = mountWithDigest(false)
await flushPromises()
await nextTick()
;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) })
await wrapper.vm.toggleDigest()
await flushPromises()
expect(global.fetch).toHaveBeenCalledWith(
'/api/user/profile',
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ email_digest_enabled: true }),
}),
)
})
it('flips emailDigestEnabled state after toggle', 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 () => ({}) })
expect(wrapper.vm.emailDigestEnabled).toBe(true) await wrapper.vm.handleSubmit({
await wrapper.vm.toggleDigest() image_id: null,
first_name: 'Test',
last_name: 'User',
email: 'test@example.com',
email_digest_enabled: true, // same as initial
push_enabled: false,
})
await flushPromises() await flushPromises()
expect(wrapper.vm.emailDigestEnabled).toBe(false) const putCall = (global.fetch as any).mock.calls.find((c: any[]) => c[1]?.method === 'PUT')
expect(putCall).toBeDefined()
const body = JSON.parse(putCall[1].body)
expect(body.email_digest_enabled).toBeUndefined()
}) })
}) })

View File

@@ -8,6 +8,7 @@
:loading="loading" :loading="loading"
:error="errorMsg" :error="errorMsg"
:title="'User Profile'" :title="'User Profile'"
:fieldErrors="{ push_enabled: pushError }"
@submit="handleSubmit" @submit="handleSubmit"
@cancel="router.back" @cancel="router.back"
@add-image="onAddImage" @add-image="onAddImage"
@@ -15,22 +16,6 @@
<template #custom-field-email="{ modelValue }"> <template #custom-field-email="{ modelValue }">
<div class="email-actions"> <div class="email-actions">
<input id="email" type="email" :value="modelValue" disabled class="readonly-input" /> <input id="email" type="email" :value="modelValue" disabled class="readonly-input" />
<ToggleField
label="Daily Digest"
:modelValue="emailDigestEnabled"
:disabled="savingDigest"
description="Receive a 9pm summary of pending chore and reward requests with one-click approve/deny links."
:error="digestError"
@update:modelValue="toggleDigest"
/>
<ToggleField
label="Push Notifications"
:modelValue="pushEnabled"
:disabled="savingPush || getPushPermissionState() === 'denied'"
description="Receive instant push notifications when a chore or reward needs your approval."
:error="pushError"
@update:modelValue="togglePush"
/>
<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>
@@ -114,16 +99,16 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } 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 ModalDialog from '../shared/ModalDialog.vue' import ModalDialog from '../shared/ModalDialog.vue'
import ToggleField from '../shared/ToggleField.vue'
import { import {
isSubscribedToPush, isSubscribedToPush,
subscribeToPushWithResult, subscribeToPushWithResult,
unsubscribeFromPush, unsubscribeFromPush,
getPushPermissionState, getPushPermissionState,
setPushOptOut,
} from '@/services/pushSubscription' } from '@/services/pushSubscription'
import { parseErrorResponse, isEmailValid } from '@/common/api' import { parseErrorResponse, isEmailValid } from '@/common/api'
import { ALREADY_MARKED } from '@/common/errorCodes' import { ALREADY_MARKED } from '@/common/errorCodes'
@@ -148,39 +133,45 @@ const showDeleteSuccess = ref(false)
const showDeleteError = ref(false) const showDeleteError = ref(false)
const deleteErrorMessage = ref('') const deleteErrorMessage = ref('')
const emailDigestEnabled = ref(true)
const savingDigest = ref(false)
const digestError = ref('')
const pushEnabled = ref(false)
const savingPush = ref(false)
const pushError = ref('') const pushError = ref('')
const pushPermissionDenied = ref(false)
const initialData = ref<{ const initialData = ref<{
image_id: string | null image_id: string | null
first_name: string first_name: string
last_name: string last_name: string
email: string email: string
email_digest_enabled: boolean
push_enabled: boolean
}>({ }>({
image_id: null, image_id: null,
first_name: '', first_name: '',
last_name: '', last_name: '',
email: '', email: '',
email_digest_enabled: true,
push_enabled: false,
}) })
const fields: Array<{ const fields = computed(() => [
name: string { name: 'image_id', label: 'Image', type: 'image' as const, imageType: 1 },
label: string { name: 'first_name', label: 'First Name', type: 'text' as const, required: true, maxlength: 64 },
type: 'image' | 'text' | 'custom' { name: 'last_name', label: 'Last Name', type: 'text' as const, required: true, maxlength: 64 },
imageType?: number { name: 'email', label: 'Email Address', type: 'custom' as const },
required?: boolean {
maxlength?: number name: 'email_digest_enabled',
}> = [ label: 'Daily Digest',
{ name: 'image_id', label: 'Image', type: 'image', imageType: 1 }, type: 'toggle' as const,
{ name: 'first_name', label: 'First Name', type: 'text', required: true, maxlength: 64 }, description:
{ name: 'last_name', label: 'Last Name', type: 'text', required: true, maxlength: 64 }, 'Receive a 9pm summary of pending chore and reward requests with one-click approve/deny links.',
{ name: 'email', label: 'Email Address', type: 'custom' }, },
] {
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,
},
])
onMounted(async () => { onMounted(async () => {
loading.value = true loading.value = true
@@ -188,14 +179,16 @@ onMounted(async () => {
const res = await fetch('/api/user/profile') const res = await fetch('/api/user/profile')
if (!res.ok) throw new Error('Failed to load profile') if (!res.ok) throw new Error('Failed to load profile')
const data = await res.json() const data = await res.json()
pushPermissionDenied.value = getPushPermissionState() === 'denied'
const pushSubscribed = await isSubscribedToPush()
initialData.value = { initialData.value = {
image_id: data.image_id || null, image_id: data.image_id || null,
first_name: data.first_name || '', first_name: data.first_name || '',
last_name: data.last_name || '', last_name: data.last_name || '',
email: data.email || '', email: data.email || '',
email_digest_enabled: data.email_digest_enabled !== false,
push_enabled: data.push_notifications_enabled !== false && pushSubscribed,
} }
emailDigestEnabled.value = data.email_digest_enabled !== false
pushEnabled.value = await isSubscribedToPush()
} catch { } catch {
errorMsg.value = 'Could not load user profile.' errorMsg.value = 'Could not load user profile.'
} finally { } finally {
@@ -217,6 +210,8 @@ function handleSubmit(form: {
first_name: string first_name: string
last_name: string last_name: string
email: string email: string
email_digest_enabled?: boolean
push_enabled?: boolean
}) { }) {
errorMsg.value = '' errorMsg.value = ''
loading.value = true loading.value = true
@@ -256,31 +251,73 @@ async function updateProfile(form: {
first_name: string first_name: string
last_name: string last_name: string
email: string email: string
email_digest_enabled?: boolean
push_enabled?: boolean
}) { }) {
fetch('/api/user/profile', { const prevDigest = initialData.value.email_digest_enabled
method: 'PUT', const prevPush = initialData.value.push_enabled
headers: { 'Content-Type': 'application/json' }, try {
body: JSON.stringify({ const body: Record<string, unknown> = {
first_name: form.first_name, first_name: form.first_name,
last_name: form.last_name, last_name: form.last_name,
image_id: form.image_id, image_id: form.image_id,
}), }
if (form.email_digest_enabled !== undefined && form.email_digest_enabled !== prevDigest) {
body.email_digest_enabled = form.email_digest_enabled
}
if (form.push_enabled !== undefined && form.push_enabled !== prevPush) {
body.push_notifications_enabled = form.push_enabled
}
const res = await fetch('/api/user/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}) })
.then(async (res) => {
if (!res.ok) throw new Error('Failed to update profile') if (!res.ok) throw new Error('Failed to update profile')
// Update initialData to reflect the saved state let actualPushEnabled = prevPush
initialData.value = { ...form } 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' modalTitle.value = 'Profile Updated'
modalSubtitle.value = '' modalSubtitle.value = ''
modalMessage.value = 'Your profile was updated successfully.' modalMessage.value = 'Your profile was updated successfully.'
showModal.value = true showModal.value = true
}) } catch {
.catch(() => {
errorMsg.value = 'Failed to update profile.' errorMsg.value = 'Failed to update profile.'
}) } finally {
.finally(() => {
loading.value = false loading.value = false
}) }
}
async function applyPushChange(newValue: boolean): Promise<boolean> {
pushError.value = ''
try {
if (newValue) {
const result = await subscribeToPushWithResult()
if (result.ok) {
setPushOptOut(false)
return true
}
pushError.value =
result.reason === 'permission_denied'
? 'Notifications are blocked. Enable them in your browser settings.'
: 'Failed to enable push notifications.'
return false
} else {
await unsubscribeFromPush()
setPushOptOut(true)
return true
}
} catch {
pushError.value = 'Failed to update push notification settings.'
return false
}
} }
async function handlePasswordModalClose() { async function handlePasswordModalClose() {
@@ -317,47 +354,6 @@ async function resetPassword() {
} }
} }
async function toggleDigest(newValue?: boolean) {
const value = newValue ?? !emailDigestEnabled.value
digestError.value = ''
savingDigest.value = true
try {
const res = await fetch('/api/user/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email_digest_enabled: value }),
})
if (!res.ok) throw new Error('Failed to update preference')
emailDigestEnabled.value = value
} catch {
digestError.value = 'Failed to save notification preference.'
} finally {
savingDigest.value = false
}
}
async function togglePush(newValue: boolean) {
pushError.value = ''
savingPush.value = true
try {
if (newValue) {
const result = await subscribeToPushWithResult()
if (result.ok) {
pushEnabled.value = true
} else if (result.reason === 'permission_denied') {
pushError.value = 'Notifications are blocked. Enable them in your browser settings.'
} else {
pushError.value = 'Failed to enable push notifications.'
}
} else {
await unsubscribeFromPush()
pushEnabled.value = false
}
} finally {
savingPush.value = false
}
}
function goToChangeParentPin() { function goToChangeParentPin() {
router.push({ name: 'ParentPinSetup' }) router.push({ name: 'ParentPinSetup' })
} }

View File

@@ -4,7 +4,16 @@
<form v-else @submit.prevent="submit" class="entity-form" ref="formRef"> <form v-else @submit.prevent="submit" class="entity-form" ref="formRef">
<template v-for="field in fields" :key="field.name"> <template v-for="field in fields" :key="field.name">
<div class="group"> <div class="group">
<label :for="field.name"> <ToggleField
v-if="field.type === 'toggle'"
:label="field.label"
:modelValue="formData[field.name]"
@update:modelValue="(val: boolean) => (formData[field.name] = val)"
:disabled="field.disabled"
:description="field.description"
:error="props.fieldErrors?.[field.name]"
/>
<label v-else :for="field.name">
{{ field.label }} {{ field.label }}
<!-- Custom field slot --> <!-- Custom field slot -->
<slot <slot
@@ -70,18 +79,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, nextTick, watch, computed } from 'vue' import { ref, onMounted, nextTick, watch, computed } from 'vue'
import ImagePicker from '@/components/utils/ImagePicker.vue' import ImagePicker from '@/components/utils/ImagePicker.vue'
import ToggleField from './ToggleField.vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import '@/assets/styles.css' import '@/assets/styles.css'
type Field = { type Field = {
name: string name: string
label: string label: string
type: 'text' | 'number' | 'image' | 'custom' type: 'text' | 'number' | 'image' | 'custom' | 'toggle'
required?: boolean required?: boolean
maxlength?: number maxlength?: number
min?: number min?: number
max?: number max?: number
imageType?: number imageType?: number
description?: string
disabled?: boolean
} }
const props = withDefaults( const props = withDefaults(
@@ -94,6 +106,7 @@ const props = withDefaults(
error?: string | null error?: string | null
title?: string title?: string
requireDirty?: boolean requireDirty?: boolean
fieldErrors?: Record<string, string>
}>(), }>(),
{ {
requireDirty: true, requireDirty: true,

View File

@@ -15,7 +15,7 @@ import {
import { getCachedImageUrl, getCachedImageBlob } from '@/common/imageCache' import { getCachedImageUrl, getCachedImageBlob } from '@/common/imageCache'
import '@/assets/styles.css' import '@/assets/styles.css'
import ModalDialog from './ModalDialog.vue' import ModalDialog from './ModalDialog.vue'
import { subscribeToPushWithResult } from '@/services/pushSubscription' import { subscribeToPushWithResult, isPushOptedOut } from '@/services/pushSubscription'
const router = useRouter() const router = useRouter()
const show = ref(false) const show = ref(false)
@@ -140,7 +140,9 @@ const submit = async () => {
} }
// Authenticate parent and navigate // Authenticate parent and navigate
authenticateParent(stayInParentMode.value) authenticateParent(stayInParentMode.value)
if (!isPushOptedOut()) {
subscribeToPushWithResult() // fire-and-forget — browser gesture is satisfied by the PIN button click subscribeToPushWithResult() // fire-and-forget — browser gesture is satisfied by the PIN button click
}
close() close()
const returnUrl = consumePendingReturnUrl() const returnUrl = consumePendingReturnUrl()
router.push(returnUrl || '/parent') router.push(returnUrl || '/parent')

View File

@@ -186,3 +186,25 @@ export async function isSubscribedToPush(): Promise<boolean> {
return false return false
} }
} }
/** localStorage key used to persist the user's explicit push opt-out across logins. */
const PUSH_OPT_OUT_KEY = 'push_opt_out'
/**
* Persist or clear the user's push opt-out preference in localStorage.
* Call with `true` when the user disables push, `false` when they enable it.
*/
export function setPushOptOut(optOut: boolean): void {
if (optOut) {
localStorage.setItem(PUSH_OPT_OUT_KEY, '1')
} else {
localStorage.removeItem(PUSH_OPT_OUT_KEY)
}
}
/**
* Returns true if the user has explicitly opted out of push notifications.
*/
export function isPushOptedOut(): boolean {
return localStorage.getItem(PUSH_OPT_OUT_KEY) === '1'
}