added in browser notifications for notifications in settings
This commit is contained in:
@@ -2,6 +2,7 @@ import { Suspense } from "react";
|
|||||||
|
|
||||||
import { AppAuthGate } from "@/components/layout/app-auth-gate";
|
import { AppAuthGate } from "@/components/layout/app-auth-gate";
|
||||||
import { DailyReminderProvider } from "@/components/reminders/daily-reminder-provider";
|
import { DailyReminderProvider } from "@/components/reminders/daily-reminder-provider";
|
||||||
|
import { InAppReminderBanner } from "@/components/reminders/in-app-reminder-banner";
|
||||||
|
|
||||||
export default function AppLayout({
|
export default function AppLayout({
|
||||||
children,
|
children,
|
||||||
@@ -12,6 +13,7 @@ export default function AppLayout({
|
|||||||
<Suspense fallback={<div className="min-h-svh bg-background" />}>
|
<Suspense fallback={<div className="min-h-svh bg-background" />}>
|
||||||
<AppAuthGate>
|
<AppAuthGate>
|
||||||
<DailyReminderProvider />
|
<DailyReminderProvider />
|
||||||
|
<InAppReminderBanner />
|
||||||
{children}
|
{children}
|
||||||
</AppAuthGate>
|
</AppAuthGate>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -17,14 +17,20 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import {
|
||||||
clearDailyReminderSentToday,
|
clearDailyReminderSentToday,
|
||||||
|
deliverTestDailyReminder,
|
||||||
getNotificationPermissionLabel,
|
getNotificationPermissionLabel,
|
||||||
getNotificationPermissionState,
|
getNotificationPermissionState,
|
||||||
isNotificationSupported,
|
isNotificationSupported,
|
||||||
requestNotificationPermission,
|
requestNotificationPermission,
|
||||||
showTestDailyReminderNotification,
|
|
||||||
} from "@/lib/daily-reminders";
|
} from "@/lib/daily-reminders";
|
||||||
import { getDailyReminderStatus } from "@/lib/daily-reminder-status";
|
import { getDailyReminderStatus } from "@/lib/daily-reminder-status";
|
||||||
import { getTodayDateKey } from "@/lib/habits-storage";
|
import { getTodayDateKey } from "@/lib/habits-storage";
|
||||||
|
import {
|
||||||
|
reminderDeliveryOptions,
|
||||||
|
usesInAppReminders,
|
||||||
|
usesSystemReminders,
|
||||||
|
type ReminderDeliveryMethod,
|
||||||
|
} from "@/lib/reminder-delivery";
|
||||||
import {
|
import {
|
||||||
avatarVibeOptions,
|
avatarVibeOptions,
|
||||||
defaultProfilePreferences,
|
defaultProfilePreferences,
|
||||||
@@ -86,7 +92,8 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
"dailyReminderEnabled" in updates ||
|
"dailyReminderEnabled" in updates ||
|
||||||
"dailyReminderTime" in updates
|
"dailyReminderTime" in updates ||
|
||||||
|
"dailyReminderDelivery" in updates
|
||||||
) {
|
) {
|
||||||
clearDailyReminderSentToday(getTodayDateKey());
|
clearDailyReminderSentToday(getTodayDateKey());
|
||||||
}
|
}
|
||||||
@@ -102,37 +109,16 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDailyReminderToggle = async (checked: boolean) => {
|
const ensureSystemReminderPermission = async (
|
||||||
if (checked) {
|
deliveryMethod: ReminderDeliveryMethod,
|
||||||
if (!isNotificationSupported()) {
|
) => {
|
||||||
setError("This browser does not support notifications.");
|
if (!usesSystemReminders(deliveryMethod)) {
|
||||||
return;
|
return true;
|
||||||
}
|
|
||||||
|
|
||||||
const permission = await requestNotificationPermission();
|
|
||||||
setNotificationPermission(permission);
|
|
||||||
|
|
||||||
if (permission !== "granted") {
|
|
||||||
setError(
|
|
||||||
permission === "denied"
|
|
||||||
? "Notifications are blocked. Enable them in your browser settings to use daily reminders."
|
|
||||||
: "Notification permission was not granted.",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setError(null);
|
|
||||||
await updatePreferences({ dailyReminderEnabled: checked });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTestNotification = async () => {
|
|
||||||
setNotificationMessage(null);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
if (!isNotificationSupported()) {
|
if (!isNotificationSupported()) {
|
||||||
setError("This browser does not support notifications.");
|
setError("This browser does not support browser (OS) notifications.");
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const permission = await requestNotificationPermission();
|
const permission = await requestNotificationPermission();
|
||||||
@@ -141,22 +127,86 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
|
|||||||
if (permission !== "granted") {
|
if (permission !== "granted") {
|
||||||
setError(
|
setError(
|
||||||
permission === "denied"
|
permission === "denied"
|
||||||
? "Notifications are blocked. Enable them in your browser settings."
|
? "Browser notifications are blocked. Enable them in your browser settings or choose In-app delivery."
|
||||||
: "Notification permission was not granted.",
|
: "Browser notification permission was not granted.",
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDailyReminderToggle = async (checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
const allowed = await ensureSystemReminderPermission(
|
||||||
|
preferences.dailyReminderDelivery,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!allowed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
await updatePreferences({ dailyReminderEnabled: checked });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeliveryMethodChange = async (
|
||||||
|
deliveryMethod: ReminderDeliveryMethod,
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
preferences.dailyReminderEnabled &&
|
||||||
|
usesSystemReminders(deliveryMethod)
|
||||||
|
) {
|
||||||
|
const allowed = await ensureSystemReminderPermission(deliveryMethod);
|
||||||
|
|
||||||
|
if (!allowed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
await updatePreferences({ dailyReminderDelivery: deliveryMethod });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestNotification = async () => {
|
||||||
|
setNotificationMessage(null);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const deliveryMethod = preferences.dailyReminderDelivery;
|
||||||
|
|
||||||
|
if (usesSystemReminders(deliveryMethod)) {
|
||||||
|
const allowed = await ensureSystemReminderPermission(deliveryMethod);
|
||||||
|
|
||||||
|
if (!allowed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = await getDailyReminderStatus();
|
||||||
|
const shown = deliverTestDailyReminder(status, deliveryMethod);
|
||||||
|
|
||||||
|
if (!shown) {
|
||||||
|
setError("Could not show a test reminder.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deliveryMethod === "in_app") {
|
||||||
|
setNotificationMessage(
|
||||||
|
"In-app test reminder sent. Look for the banner above the bottom navigation.",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = await getDailyReminderStatus();
|
if (deliveryMethod === "system") {
|
||||||
const shown = showTestDailyReminderNotification(status);
|
setNotificationMessage(
|
||||||
|
"Browser test notification sent. Check your browser or OS notification center.",
|
||||||
if (!shown) {
|
);
|
||||||
setError("Could not show a test notification.");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setNotificationMessage(
|
setNotificationMessage(
|
||||||
"Test notification sent. Check the corner of your screen or Windows notification center.",
|
"Test reminder sent in-app and through your browser notification center.",
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -239,19 +289,44 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
|
|||||||
<Label htmlFor="daily-reminder">Daily reminder</Label>
|
<Label htmlFor="daily-reminder">Daily reminder</Label>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="daily-reminder"
|
id="daily-reminder"
|
||||||
disabled={isSaving || !isNotificationSupported()}
|
disabled={isSaving}
|
||||||
checked={preferences.dailyReminderEnabled}
|
checked={preferences.dailyReminderEnabled}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
void handleDailyReminderToggle(checked === true)
|
void handleDailyReminderToggle(checked === true)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
|
<div className="space-y-2">
|
||||||
<span className="text-sm">Browser permission</span>
|
<Label>Reminder delivery</Label>
|
||||||
<Badge variant="secondary">
|
<div className="space-y-2">
|
||||||
{getNotificationPermissionLabel(notificationPermission)}
|
{reminderDeliveryOptions.map((option) => (
|
||||||
</Badge>
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={() => void handleDeliveryMethodChange(option.value)}
|
||||||
|
className={`w-full rounded-lg border px-3 py-3 text-left transition-colors ${
|
||||||
|
preferences.dailyReminderDelivery === option.value
|
||||||
|
? "border-primary bg-primary/10"
|
||||||
|
: "hover:bg-muted/40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium">{option.label}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{option.description}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{usesSystemReminders(preferences.dailyReminderDelivery) ? (
|
||||||
|
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
|
||||||
|
<span className="text-sm">Browser permission</span>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{getNotificationPermissionLabel(notificationPermission)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="reminder-time">Daily reminder time</Label>
|
<Label htmlFor="reminder-time">Daily reminder time</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -265,21 +340,19 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Reminders use your browser's local time and notify you once
|
In-app reminders appear inside Habit Pet while a tab is open.
|
||||||
per day when the quiz or habits are still incomplete. Keep this app
|
Browser (OS) reminders use the Web Notifications API and may appear
|
||||||
open in a tab for reminders to fire. On Windows, check Focus Assist
|
in your system notification center. Reminders fire once per day at
|
||||||
if notifications are hidden.
|
your chosen local time.
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={
|
disabled={isSaving}
|
||||||
isSaving || notificationPermission !== "granted"
|
|
||||||
}
|
|
||||||
onClick={() => void handleTestNotification()}
|
onClick={() => void handleTestNotification()}
|
||||||
>
|
>
|
||||||
Send test notification
|
Send test reminder
|
||||||
</Button>
|
</Button>
|
||||||
{notificationMessage ? (
|
{notificationMessage ? (
|
||||||
<p className="text-xs text-emerald-600">{notificationMessage}</p>
|
<p className="text-xs text-emerald-600">{notificationMessage}</p>
|
||||||
|
|||||||
@@ -5,17 +5,18 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
|
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
|
||||||
import { getDailyReminderStatus } from "@/lib/daily-reminder-status";
|
import { getDailyReminderStatus } from "@/lib/daily-reminder-status";
|
||||||
import {
|
import {
|
||||||
|
deliverDailyReminder,
|
||||||
getNotificationPermissionState,
|
getNotificationPermissionState,
|
||||||
getReminderCheckIntervalMs,
|
getReminderCheckIntervalMs,
|
||||||
hasReachedReminderTime,
|
hasReachedReminderTime,
|
||||||
markDailyReminderSentToday,
|
markDailyReminderSentToday,
|
||||||
showDailyReminderNotification,
|
|
||||||
wasDailyReminderSentToday,
|
wasDailyReminderSentToday,
|
||||||
} from "@/lib/daily-reminders";
|
} from "@/lib/daily-reminders";
|
||||||
import {
|
import {
|
||||||
getProfilePreferences,
|
getProfilePreferences,
|
||||||
type ProfilePreferences,
|
type ProfilePreferences,
|
||||||
} from "@/lib/profile-preferences-storage";
|
} from "@/lib/profile-preferences-storage";
|
||||||
|
import { usesInAppReminders, usesSystemReminders } from "@/lib/reminder-delivery";
|
||||||
|
|
||||||
export function DailyReminderProvider() {
|
export function DailyReminderProvider() {
|
||||||
const [preferences, setPreferences] = useState<ProfilePreferences | null>(
|
const [preferences, setPreferences] = useState<ProfilePreferences | null>(
|
||||||
@@ -47,7 +48,11 @@ export function DailyReminderProvider() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (getNotificationPermissionState() !== "granted") {
|
if (
|
||||||
|
usesSystemReminders(activePreferences.dailyReminderDelivery) &&
|
||||||
|
!usesInAppReminders(activePreferences.dailyReminderDelivery) &&
|
||||||
|
getNotificationPermissionState() !== "granted"
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +67,10 @@ export function DailyReminderProvider() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status.needsReminder) {
|
if (status.needsReminder) {
|
||||||
showDailyReminderNotification(status);
|
deliverDailyReminder(
|
||||||
|
status,
|
||||||
|
activePreferences.dailyReminderDelivery,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
markDailyReminderSentToday(status.dateKey);
|
markDailyReminderSentToday(status.dateKey);
|
||||||
|
|||||||
83
components/reminders/in-app-reminder-banner.tsx
Normal file
83
components/reminders/in-app-reminder-banner.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Bell, X } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
IN_APP_REMINDER_EVENT,
|
||||||
|
type InAppReminderPayload,
|
||||||
|
} from "@/lib/in-app-reminders";
|
||||||
|
|
||||||
|
export function InAppReminderBanner() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [reminder, setReminder] = useState<InAppReminderPayload | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleReminder = (event: Event) => {
|
||||||
|
const customEvent = event as CustomEvent<InAppReminderPayload>;
|
||||||
|
setReminder(customEvent.detail);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener(IN_APP_REMINDER_EVENT, handleReminder);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(IN_APP_REMINDER_EVENT, handleReminder);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!reminder) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
className="fixed inset-x-0 bottom-16 z-50 px-4"
|
||||||
|
>
|
||||||
|
<div className="mx-auto flex max-w-lg items-start gap-3 rounded-xl border border-primary/30 bg-card p-4 shadow-lg">
|
||||||
|
<div className="rounded-full bg-primary/10 p-2">
|
||||||
|
<Bell aria-hidden="true" className="h-4 w-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium leading-tight">{reminder.title}</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{reminder.body}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setReminder(null);
|
||||||
|
router.push(reminder.targetUrl);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Open Pet tab
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setReminder(null)}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
aria-label="Dismiss reminder"
|
||||||
|
onClick={() => setReminder(null)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -123,6 +123,9 @@ function parseProfilePreferences(
|
|||||||
dailyReminderTime:
|
dailyReminderTime:
|
||||||
parsed.dailyReminderTime ??
|
parsed.dailyReminderTime ??
|
||||||
defaultProfilePreferences.dailyReminderTime,
|
defaultProfilePreferences.dailyReminderTime,
|
||||||
|
dailyReminderDelivery:
|
||||||
|
parsed.dailyReminderDelivery ??
|
||||||
|
defaultProfilePreferences.dailyReminderDelivery,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ function parseStoredPreferences(
|
|||||||
avatarVibe: "Calm",
|
avatarVibe: "Calm",
|
||||||
dailyReminderEnabled: false,
|
dailyReminderEnabled: false,
|
||||||
dailyReminderTime: "20:00",
|
dailyReminderTime: "20:00",
|
||||||
|
dailyReminderDelivery: "in_app",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ function parseStoredPreferences(
|
|||||||
avatarVibe: "Calm",
|
avatarVibe: "Calm",
|
||||||
dailyReminderEnabled: false,
|
dailyReminderEnabled: false,
|
||||||
dailyReminderTime: "20:00",
|
dailyReminderTime: "20:00",
|
||||||
|
dailyReminderDelivery: "in_app",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,5 +70,6 @@ function parseStoredPreferences(
|
|||||||
avatarVibe: "Calm",
|
avatarVibe: "Calm",
|
||||||
dailyReminderEnabled: false,
|
dailyReminderEnabled: false,
|
||||||
dailyReminderTime: "20:00",
|
dailyReminderTime: "20:00",
|
||||||
|
dailyReminderDelivery: "in_app",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import type { DailyReminderStatus } from "@/lib/daily-reminder-status";
|
import type { DailyReminderStatus } from "@/lib/daily-reminder-status";
|
||||||
|
import { showInAppDailyReminder } from "@/lib/in-app-reminders";
|
||||||
import { routes } from "@/lib/routes";
|
import { routes } from "@/lib/routes";
|
||||||
|
import {
|
||||||
|
type ReminderDeliveryMethod,
|
||||||
|
usesInAppReminders,
|
||||||
|
usesSystemReminders,
|
||||||
|
} from "@/lib/reminder-delivery";
|
||||||
|
|
||||||
const REMINDER_SENT_STORAGE_PREFIX = "habit-pet-daily-reminder-sent";
|
const REMINDER_SENT_STORAGE_PREFIX = "habit-pet-daily-reminder-sent";
|
||||||
const REMINDER_CHECK_INTERVAL_MS = 15_000;
|
const REMINDER_CHECK_INTERVAL_MS = 15_000;
|
||||||
@@ -135,7 +141,7 @@ export function buildDailyReminderNotification(status: DailyReminderStatus) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showDailyReminderNotification(
|
export function showSystemDailyReminderNotification(
|
||||||
status: DailyReminderStatus,
|
status: DailyReminderStatus,
|
||||||
) {
|
) {
|
||||||
if (!isNotificationSupported() || Notification.permission !== "granted") {
|
if (!isNotificationSupported() || Notification.permission !== "granted") {
|
||||||
@@ -160,7 +166,7 @@ export function showDailyReminderNotification(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showTestDailyReminderNotification(
|
export function showTestSystemDailyReminderNotification(
|
||||||
status: DailyReminderStatus,
|
status: DailyReminderStatus,
|
||||||
) {
|
) {
|
||||||
if (!isNotificationSupported() || Notification.permission !== "granted") {
|
if (!isNotificationSupported() || Notification.permission !== "granted") {
|
||||||
@@ -169,7 +175,7 @@ export function showTestDailyReminderNotification(
|
|||||||
|
|
||||||
const { body, targetUrl } = buildDailyReminderNotification(status);
|
const { body, targetUrl } = buildDailyReminderNotification(status);
|
||||||
const notification = new Notification("Test: Habit Pet reminder", {
|
const notification = new Notification("Test: Habit Pet reminder", {
|
||||||
body: `This is a test notification. ${body}`,
|
body: `This is a browser test notification. ${body}`,
|
||||||
tag: `habit-pet-daily-reminder-test-${Date.now()}`,
|
tag: `habit-pet-daily-reminder-test-${Date.now()}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -185,6 +191,61 @@ export function showTestDailyReminderNotification(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deliverDailyReminder(
|
||||||
|
status: DailyReminderStatus,
|
||||||
|
deliveryMethod: ReminderDeliveryMethod,
|
||||||
|
) {
|
||||||
|
let delivered = false;
|
||||||
|
|
||||||
|
if (usesInAppReminders(deliveryMethod)) {
|
||||||
|
const { title, body, targetUrl } = buildDailyReminderNotification(status);
|
||||||
|
delivered =
|
||||||
|
showInAppDailyReminder({ title, body, targetUrl }) || delivered;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usesSystemReminders(deliveryMethod)) {
|
||||||
|
delivered =
|
||||||
|
showSystemDailyReminderNotification(status) || delivered;
|
||||||
|
}
|
||||||
|
|
||||||
|
return delivered;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deliverTestDailyReminder(
|
||||||
|
status: DailyReminderStatus,
|
||||||
|
deliveryMethod: ReminderDeliveryMethod,
|
||||||
|
) {
|
||||||
|
let delivered = false;
|
||||||
|
|
||||||
|
if (usesInAppReminders(deliveryMethod)) {
|
||||||
|
const { title, body, targetUrl } = buildDailyReminderNotification(status);
|
||||||
|
delivered =
|
||||||
|
showInAppDailyReminder({
|
||||||
|
title: `Test: ${title}`,
|
||||||
|
body: `This is an in-app test reminder. ${body}`,
|
||||||
|
targetUrl,
|
||||||
|
isTest: true,
|
||||||
|
}) || delivered;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usesSystemReminders(deliveryMethod)) {
|
||||||
|
delivered =
|
||||||
|
showTestSystemDailyReminderNotification(status) || delivered;
|
||||||
|
}
|
||||||
|
|
||||||
|
return delivered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use showSystemDailyReminderNotification */
|
||||||
|
export function showDailyReminderNotification(status: DailyReminderStatus) {
|
||||||
|
return showSystemDailyReminderNotification(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use showTestSystemDailyReminderNotification */
|
||||||
|
export function showTestDailyReminderNotification(status: DailyReminderStatus) {
|
||||||
|
return showTestSystemDailyReminderNotification(status);
|
||||||
|
}
|
||||||
|
|
||||||
export function getReminderCheckIntervalMs() {
|
export function getReminderCheckIntervalMs() {
|
||||||
return REMINDER_CHECK_INTERVAL_MS;
|
return REMINDER_CHECK_INTERVAL_MS;
|
||||||
}
|
}
|
||||||
|
|||||||
38
lib/in-app-reminders.ts
Normal file
38
lib/in-app-reminders.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { routes } from "@/lib/routes";
|
||||||
|
|
||||||
|
export const IN_APP_REMINDER_EVENT = "habit-pet-in-app-reminder";
|
||||||
|
|
||||||
|
export type InAppReminderPayload = {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
targetUrl: string;
|
||||||
|
isTest?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function dispatchInAppReminder(payload: InAppReminderPayload) {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent<InAppReminderPayload>(IN_APP_REMINDER_EVENT, {
|
||||||
|
detail: payload,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showInAppDailyReminder(input: {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
targetUrl?: string;
|
||||||
|
isTest?: boolean;
|
||||||
|
}) {
|
||||||
|
return dispatchInAppReminder({
|
||||||
|
title: input.title,
|
||||||
|
body: input.body,
|
||||||
|
targetUrl: input.targetUrl ?? routes.avatar,
|
||||||
|
isTest: input.isTest,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
import { notifyHabitPetDataUpdated } from "@/lib/app-events";
|
import { notifyHabitPetDataUpdated } from "@/lib/app-events";
|
||||||
|
import {
|
||||||
|
normalizeReminderDeliveryMethod,
|
||||||
|
type ReminderDeliveryMethod,
|
||||||
|
} from "@/lib/reminder-delivery";
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
|
||||||
export type ProfilePreferences = {
|
export type ProfilePreferences = {
|
||||||
@@ -6,6 +10,7 @@ export type ProfilePreferences = {
|
|||||||
avatarVibe: string;
|
avatarVibe: string;
|
||||||
dailyReminderEnabled: boolean;
|
dailyReminderEnabled: boolean;
|
||||||
dailyReminderTime: string;
|
dailyReminderTime: string;
|
||||||
|
dailyReminderDelivery: ReminderDeliveryMethod;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StoredProfilePreferences = {
|
type StoredProfilePreferences = {
|
||||||
@@ -13,6 +18,7 @@ type StoredProfilePreferences = {
|
|||||||
avatarVibe?: string;
|
avatarVibe?: string;
|
||||||
dailyReminderEnabled?: boolean;
|
dailyReminderEnabled?: boolean;
|
||||||
dailyReminderTime?: string;
|
dailyReminderTime?: string;
|
||||||
|
dailyReminderDelivery?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProfileRow = {
|
type ProfileRow = {
|
||||||
@@ -34,6 +40,7 @@ export const defaultProfilePreferences: ProfilePreferences = {
|
|||||||
avatarVibe: "Calm",
|
avatarVibe: "Calm",
|
||||||
dailyReminderEnabled: false,
|
dailyReminderEnabled: false,
|
||||||
dailyReminderTime: "20:00",
|
dailyReminderTime: "20:00",
|
||||||
|
dailyReminderDelivery: "in_app",
|
||||||
};
|
};
|
||||||
|
|
||||||
function isFocusTopic(value: string) {
|
function isFocusTopic(value: string) {
|
||||||
@@ -71,6 +78,9 @@ function normalizePreferences(
|
|||||||
defaultProfilePreferences.dailyReminderEnabled,
|
defaultProfilePreferences.dailyReminderEnabled,
|
||||||
dailyReminderTime:
|
dailyReminderTime:
|
||||||
partial.dailyReminderTime ?? defaultProfilePreferences.dailyReminderTime,
|
partial.dailyReminderTime ?? defaultProfilePreferences.dailyReminderTime,
|
||||||
|
dailyReminderDelivery: normalizeReminderDeliveryMethod(
|
||||||
|
partial.dailyReminderDelivery,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +89,7 @@ function parseStoredPreferencesJson(raw: string): Partial<ProfilePreferences> {
|
|||||||
const parsed = JSON.parse(raw) as StoredProfilePreferences;
|
const parsed = JSON.parse(raw) as StoredProfilePreferences;
|
||||||
|
|
||||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||||
return parsed;
|
return parsed as Partial<ProfilePreferences>;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return { focusTopic: raw };
|
return { focusTopic: raw };
|
||||||
@@ -112,6 +122,7 @@ function serializeFocusTopicsField(preferences: ProfilePreferences) {
|
|||||||
avatarVibe: preferences.avatarVibe,
|
avatarVibe: preferences.avatarVibe,
|
||||||
dailyReminderEnabled: preferences.dailyReminderEnabled,
|
dailyReminderEnabled: preferences.dailyReminderEnabled,
|
||||||
dailyReminderTime: preferences.dailyReminderTime,
|
dailyReminderTime: preferences.dailyReminderTime,
|
||||||
|
dailyReminderDelivery: preferences.dailyReminderDelivery,
|
||||||
};
|
};
|
||||||
|
|
||||||
return JSON.stringify(payload);
|
return JSON.stringify(payload);
|
||||||
|
|||||||
53
lib/reminder-delivery.ts
Normal file
53
lib/reminder-delivery.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
export const reminderDeliveryOptions = [
|
||||||
|
{
|
||||||
|
value: "in_app",
|
||||||
|
label: "In-app",
|
||||||
|
description:
|
||||||
|
"Show reminders inside Habit Pet when you have the site open in a tab.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "system",
|
||||||
|
label: "Browser (OS)",
|
||||||
|
description:
|
||||||
|
"Use the Web Notifications API. Your browser may show these in the Windows or macOS notification center.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "both",
|
||||||
|
label: "Both",
|
||||||
|
description: "In-app banners plus browser notifications when permitted.",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ReminderDeliveryMethod =
|
||||||
|
(typeof reminderDeliveryOptions)[number]["value"];
|
||||||
|
|
||||||
|
export function isReminderDeliveryMethod(
|
||||||
|
value: string | undefined,
|
||||||
|
): value is ReminderDeliveryMethod {
|
||||||
|
return reminderDeliveryOptions.some((option) => option.value === value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeReminderDeliveryMethod(
|
||||||
|
value: string | undefined,
|
||||||
|
): ReminderDeliveryMethod {
|
||||||
|
if (value && isReminderDeliveryMethod(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "in_app";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usesInAppReminders(method: ReminderDeliveryMethod) {
|
||||||
|
return method === "in_app" || method === "both";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usesSystemReminders(method: ReminderDeliveryMethod) {
|
||||||
|
return method === "system" || method === "both";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReminderDeliveryLabel(method: ReminderDeliveryMethod) {
|
||||||
|
return (
|
||||||
|
reminderDeliveryOptions.find((option) => option.value === method)?.label ??
|
||||||
|
"In-app"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user