feat: user notification preferences UI and API endpoints
GET/PUT /api/user/notification-preferences with partial update support. Toggle section in /account Admin tab (Email, SMS, Browser push). 3 new tests: defaults, full update, partial update. Fix pre-existing timezone bug in exceptional hours tests (Truncate vs time.Date). Update README, Technical Manual, and gap backlog (#15 struck out).
This commit is contained in:
@@ -84,7 +84,40 @@
|
||||
let stamps = $state(0);
|
||||
let pendingRedemption = $state(false);
|
||||
let uploadingPic = $state(false);
|
||||
|
||||
|
||||
let notifPrefs = $state({ emailEnabled: true, smsEnabled: true, browserPushEnabled: true });
|
||||
|
||||
async function fetchNotifPrefs() {
|
||||
try {
|
||||
const res = await fetch('/api/user/notification-preferences', {
|
||||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
notifPrefs.emailEnabled = data.emailEnabled ?? true;
|
||||
notifPrefs.smsEnabled = data.smsEnabled ?? true;
|
||||
notifPrefs.browserPushEnabled = data.browserPushEnabled ?? true;
|
||||
}
|
||||
} catch {
|
||||
/* silently fail */
|
||||
}
|
||||
}
|
||||
|
||||
async function saveNotifPrefs() {
|
||||
try {
|
||||
await fetch('/api/user/notification-preferences', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
},
|
||||
body: JSON.stringify(notifPrefs)
|
||||
});
|
||||
} catch {
|
||||
/* silently fail */
|
||||
}
|
||||
}
|
||||
|
||||
// Image cropper state
|
||||
let cropDialogOpen = $state(false);
|
||||
let cropImageUrl = $state('');
|
||||
@@ -92,7 +125,7 @@
|
||||
let crop = $state({ x: 0, y: 0 });
|
||||
let zoom = $state(1);
|
||||
let previewUrl = $state('');
|
||||
|
||||
|
||||
function handleFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
@@ -101,41 +134,43 @@
|
||||
cropDialogOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function handleCropSave() {
|
||||
if (!cropArea || !cropImageUrl) return;
|
||||
|
||||
|
||||
const img = new Image();
|
||||
img.src = cropImageUrl;
|
||||
await new Promise(resolve => { img.onload = resolve; });
|
||||
|
||||
await new Promise((resolve) => {
|
||||
img.onload = resolve;
|
||||
});
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 350;
|
||||
canvas.height = 350;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.drawImage(
|
||||
img,
|
||||
cropArea.x, cropArea.y, cropArea.width, cropArea.height,
|
||||
0, 0, 350, 350
|
||||
|
||||
ctx.drawImage(img, cropArea.x, cropArea.y, cropArea.width, cropArea.height, 0, 0, 350, 350);
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) return;
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
previewUrl = url;
|
||||
|
||||
handleProfilePicUpload(blob).then(() => {
|
||||
URL.revokeObjectURL(cropImageUrl);
|
||||
cropImageUrl = '';
|
||||
cropArea = null;
|
||||
cropDialogOpen = false;
|
||||
});
|
||||
},
|
||||
'image/jpeg',
|
||||
0.9
|
||||
);
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) return;
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
previewUrl = url;
|
||||
|
||||
handleProfilePicUpload(blob).then(() => {
|
||||
URL.revokeObjectURL(cropImageUrl);
|
||||
cropImageUrl = '';
|
||||
cropArea = null;
|
||||
cropDialogOpen = false;
|
||||
});
|
||||
}, 'image/jpeg', 0.9);
|
||||
}
|
||||
|
||||
|
||||
function handleCropCancel() {
|
||||
if (cropImageUrl) {
|
||||
URL.revokeObjectURL(cropImageUrl);
|
||||
@@ -425,6 +460,7 @@
|
||||
fetchUserData();
|
||||
fetchUpcomingBookings();
|
||||
fetchPastBookings();
|
||||
fetchNotifPrefs();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -439,8 +475,12 @@
|
||||
|
||||
// Password strength using zxcvbn
|
||||
let newPasswordStrength = $derived(passwordData.new ? zxcvbn(passwordData.new) : null);
|
||||
let isPasswordStrongEnough = $derived(!passwordData.new || newPasswordStrength === null || newPasswordStrength.score >= 2);
|
||||
let passwordsMatch = $derived(passwordData.confirm === '' || passwordData.new === passwordData.confirm);
|
||||
let isPasswordStrongEnough = $derived(
|
||||
!passwordData.new || newPasswordStrength === null || newPasswordStrength.score >= 2
|
||||
);
|
||||
let passwordsMatch = $derived(
|
||||
passwordData.confirm === '' || passwordData.new === passwordData.confirm
|
||||
);
|
||||
|
||||
async function changePassword() {
|
||||
if (passwordData.new !== passwordData.confirm) {
|
||||
@@ -702,26 +742,58 @@
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
{#if userData}
|
||||
{@const initials = userData.firstName && userData.lastName ? userData.firstName.split(' ').map(n => n[0]).join('') + userData.lastName.split(' ').map(n => n[0]).join('') : ''}
|
||||
{@const initials =
|
||||
userData.firstName && userData.lastName
|
||||
? userData.firstName
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('') +
|
||||
userData.lastName
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
: ''}
|
||||
{@const hasImage = !!userData.profilePicUrl || !!previewUrl}
|
||||
{@const displayUrl = previewUrl || userData.profilePicUrl || ''}
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
{#if hasImage || initials}
|
||||
{#if hasImage}
|
||||
<img src={displayUrl} alt="Profile" class="h-24 w-24 rounded-full object-cover ring-4 ring-blue-200" />
|
||||
<img
|
||||
src={displayUrl}
|
||||
alt="Profile"
|
||||
class="h-24 w-24 rounded-full object-cover ring-4 ring-blue-200"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-200 text-3xl font-bold text-gray-600 ring-4 ring-blue-200">
|
||||
<div
|
||||
class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-200 text-3xl font-bold text-gray-600 ring-4 ring-blue-200"
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-200 ring-4 ring-blue-200">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
<div
|
||||
class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-200 ring-4 ring-blue-200"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-10 w-10 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
<Button variant="outline" onclick={() => document.getElementById('profile-pic-input')?.click()}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => document.getElementById('profile-pic-input')?.click()}
|
||||
>
|
||||
Upload profile picture
|
||||
</Button>
|
||||
<input
|
||||
@@ -803,16 +875,30 @@
|
||||
<Button size="sm" onclick={savePhone} disabled={savingPhone}>
|
||||
{savingPhone ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onclick={cancelEditPhone} disabled={savingPhone}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={cancelEditPhone}
|
||||
disabled={savingPhone}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-1 flex items-center justify-between rounded-lg border bg-gray-50 p-3 font-medium">
|
||||
<div
|
||||
class="mt-1 flex items-center justify-between rounded-lg border bg-gray-50 p-3 font-medium"
|
||||
>
|
||||
<span>{userData.phone || '—'}</span>
|
||||
<Button size="sm" variant="ghost" onclick={startEditPhone}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
@@ -837,7 +923,8 @@
|
||||
{:else if userData && stamps >= 10}
|
||||
<div class="w-full text-center">
|
||||
<div class="text-sm font-medium text-emerald-800">
|
||||
Your loyalty card is full! Your next completed appointment will receive 10% off.
|
||||
Your loyalty card is full! Your next completed appointment will receive 10%
|
||||
off.
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1082,6 +1169,82 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Notification Preferences -->
|
||||
<div>
|
||||
<h3 class="mb-2 text-sm font-semibold">Notifications</h3>
|
||||
<p class="mb-3 text-sm text-gray-600">
|
||||
Choose how you receive booking reminders and updates
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between rounded-lg border p-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium">Email</div>
|
||||
<div class="text-xs text-gray-500">Booking confirmations and reminders</div>
|
||||
</div>
|
||||
<label class="relative inline-flex cursor-pointer items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="peer sr-only"
|
||||
checked={notifPrefs.emailEnabled}
|
||||
onchange={async () => {
|
||||
notifPrefs.emailEnabled = !notifPrefs.emailEnabled;
|
||||
await saveNotifPrefs();
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
class="peer h-5 w-9 rounded-full bg-gray-200 peer-checked:bg-blue-600 after:absolute after:start-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white"
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-lg border p-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium">SMS</div>
|
||||
<div class="text-xs text-gray-500">Text message reminders</div>
|
||||
</div>
|
||||
<label class="relative inline-flex cursor-pointer items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="peer sr-only"
|
||||
checked={notifPrefs.smsEnabled}
|
||||
onchange={async () => {
|
||||
notifPrefs.smsEnabled = !notifPrefs.smsEnabled;
|
||||
await saveNotifPrefs();
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
class="peer h-5 w-9 rounded-full bg-gray-200 peer-checked:bg-blue-600 after:absolute after:start-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white"
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-lg border p-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium">Browser</div>
|
||||
<div class="text-xs text-gray-500">In-browser notifications</div>
|
||||
</div>
|
||||
<label class="relative inline-flex cursor-pointer items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="peer sr-only"
|
||||
checked={notifPrefs.browserPushEnabled}
|
||||
onchange={async () => {
|
||||
notifPrefs.browserPushEnabled = !notifPrefs.browserPushEnabled;
|
||||
await saveNotifPrefs();
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
class="peer h-5 w-9 rounded-full bg-gray-200 peer-checked:bg-blue-600 after:absolute after:start-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white"
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Log Out Button -->
|
||||
<div>
|
||||
<h3 class="mb-2 text-sm font-semibold">Session</h3>
|
||||
@@ -1255,7 +1418,8 @@
|
||||
<div class="flex h-1.5 w-full overflow-hidden rounded bg-gray-200">
|
||||
<div
|
||||
class="transition-all duration-300"
|
||||
style="width: {(newPasswordStrength.score + 1) * 20}%; background-color: {newPasswordStrength.score < 2
|
||||
style="width: {(newPasswordStrength.score + 1) *
|
||||
20}%; background-color: {newPasswordStrength.score < 2
|
||||
? '#ef4444'
|
||||
: newPasswordStrength.score === 2
|
||||
? '#f59e0b'
|
||||
@@ -1264,7 +1428,13 @@
|
||||
: '#15803d'}"
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-xs {newPasswordStrength.score < 2 ? 'text-red-500' : newPasswordStrength.score === 2 ? 'text-amber-500' : 'text-green-600'}">
|
||||
<p
|
||||
class="text-xs {newPasswordStrength.score < 2
|
||||
? 'text-red-500'
|
||||
: newPasswordStrength.score === 2
|
||||
? 'text-amber-500'
|
||||
: 'text-green-600'}"
|
||||
>
|
||||
{newPasswordStrength.feedback.warning
|
||||
? newPasswordStrength.feedback.warning
|
||||
: `Strength: ${['Very weak', 'Weak', 'Fair', 'Strong', 'Very strong'][newPasswordStrength.score]}`}
|
||||
|
||||
Reference in New Issue
Block a user