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
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m50s
This commit is contained in:
@@ -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')
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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'
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user