seo improvement and updated notifs

This commit is contained in:
2026-04-13 12:27:30 +05:30
parent df4bb88f70
commit 34254f94f9
26 changed files with 941 additions and 58 deletions

View File

@@ -1,18 +1,21 @@
import { lazy, Suspense } from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider } from './contexts/AuthContext'
import { ProtectedRoute } from './components/ProtectedRoute'
import HomePage from './pages/HomePage'
import HistoryPage from './pages/HistoryPage'
import SettingsPage from './pages/SettingsPage'
import LoginPage from './pages/LoginPage'
import PrivacyPage from './pages/PrivacyPage'
import AboutPage from './pages/AboutPage'
import './App.css'
const HomePage = lazy(() => import('./pages/HomePage'))
const HistoryPage = lazy(() => import('./pages/HistoryPage'))
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
const LoginPage = lazy(() => import('./pages/LoginPage'))
const PrivacyPage = lazy(() => import('./pages/PrivacyPage'))
const AboutPage = lazy(() => import('./pages/AboutPage'))
function App() {
return (
<AuthProvider>
<BrowserRouter>
<Suspense fallback={null}>
<Routes>
<Route path="/" element={<LoginPage />} />
<Route
@@ -43,6 +46,7 @@ function App() {
<Route path="/about" element={<AboutPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</BrowserRouter>
</AuthProvider>
)

43
src/hooks/reminderApi.ts Normal file
View File

@@ -0,0 +1,43 @@
/** API calls specific to FCM token registration and reminder settings. */
const BASE = import.meta.env.VITE_API_URL || 'http://localhost:8001/api'
async function post(url: string, body: unknown, token: string) {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
credentials: 'include',
body: JSON.stringify(body),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.detail || res.statusText)
}
return res.json()
}
async function put(url: string, body: unknown, token: string) {
const res = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
credentials: 'include',
body: JSON.stringify(body),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.detail || res.statusText)
}
return res.json()
}
export function saveFcmToken(userId: string, fcmToken: string, authToken: string) {
return post(`${BASE}/notifications/fcm-token`, { userId, fcmToken }, authToken)
}
export function saveReminderSettings(
userId: string,
settings: { time?: string; enabled: boolean; timezone?: string },
authToken: string
) {
return put(`${BASE}/notifications/reminder/${userId}`, settings, authToken)
}

43
src/hooks/usePageMeta.ts Normal file
View File

@@ -0,0 +1,43 @@
import { useEffect } from 'react'
interface PageMeta {
title: string
description: string
canonical: string
ogTitle?: string
ogDescription?: string
}
export function usePageMeta({ title, description, canonical, ogTitle, ogDescription }: PageMeta) {
useEffect(() => {
document.title = title
setMeta('name', 'description', description)
setMeta('property', 'og:title', ogTitle ?? title)
setMeta('property', 'og:description', ogDescription ?? description)
setMeta('property', 'og:url', canonical)
setMeta('name', 'twitter:title', ogTitle ?? title)
setMeta('name', 'twitter:description', ogDescription ?? description)
setLink('canonical', canonical)
}, [title, description, canonical, ogTitle, ogDescription])
}
function setMeta(attr: 'name' | 'property', key: string, value: string) {
let el = document.querySelector<HTMLMetaElement>(`meta[${attr}="${key}"]`)
if (!el) {
el = document.createElement('meta')
el.setAttribute(attr, key)
document.head.appendChild(el)
}
el.setAttribute('content', value)
}
function setLink(rel: string, href: string) {
let el = document.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`)
if (!el) {
el = document.createElement('link')
el.setAttribute('rel', rel)
document.head.appendChild(el)
}
el.setAttribute('href', href)
}

115
src/hooks/useReminder.ts Normal file
View File

@@ -0,0 +1,115 @@
/**
* Daily reminder — uses Firebase Cloud Messaging (FCM) for true push notifications.
* Works even when the browser is fully closed (on mobile PWA).
*
* Flow:
* 1. User picks a time in Settings → enableReminder() is called
* 2. Browser notification permission is requested
* 3. FCM token is fetched via the firebase-messaging-sw.js service worker
* 4. Token + reminder settings are saved to the backend
* 5. Backend scheduler sends a push at the right time each day
*/
import { getToken, onMessage } from 'firebase/messaging'
import { messagingPromise } from '../lib/firebase'
import { saveFcmToken, saveReminderSettings } from './reminderApi'
const VAPID_KEY = import.meta.env.VITE_FIREBASE_VAPID_KEY
export const REMINDER_TIME_KEY = 'gj-reminder-time'
export const REMINDER_ENABLED_KEY = 'gj-reminder-enabled'
export function getSavedReminderTime(): string | null {
return localStorage.getItem(REMINDER_TIME_KEY)
}
export function isReminderEnabled(): boolean {
return localStorage.getItem(REMINDER_ENABLED_KEY) === 'true'
}
/** Get FCM token using the dedicated firebase-messaging SW. */
async function getFcmToken(): Promise<string | null> {
const messaging = await messagingPromise
if (!messaging) return null
const swReg = await navigator.serviceWorker.register('/firebase-messaging-sw.js', { scope: '/' })
await navigator.serviceWorker.ready
return getToken(messaging, { vapidKey: VAPID_KEY, serviceWorkerRegistration: swReg })
}
/**
* Request permission, get FCM token, and save reminder settings to backend.
* Returns an error string on failure, or null on success.
*/
export async function enableReminder(
timeStr: string,
userId: string,
authToken: string
): Promise<string | null> {
if (!('Notification' in window)) {
return 'Notifications are not supported in this browser.'
}
let perm = Notification.permission
if (perm === 'default') {
perm = await Notification.requestPermission()
}
if (perm !== 'granted') {
return 'Permission denied. To enable reminders, allow notifications for this site in your browser settings.'
}
try {
const fcmToken = await getFcmToken()
if (!fcmToken) {
return 'Push notifications are not supported in this browser. Try Chrome or Edge.'
}
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
await saveFcmToken(userId, fcmToken, authToken)
await saveReminderSettings(userId, { time: timeStr, enabled: true, timezone }, authToken)
localStorage.setItem(REMINDER_TIME_KEY, timeStr)
localStorage.setItem(REMINDER_ENABLED_KEY, 'true')
return null
} catch (err) {
console.error('FCM reminder setup failed', err)
return 'Failed to set up push notification. Please try again.'
}
}
/** Pause the reminder (keeps the saved time). */
export async function disableReminder(userId: string, authToken: string): Promise<void> {
await saveReminderSettings(userId, { enabled: false }, authToken)
localStorage.setItem(REMINDER_ENABLED_KEY, 'false')
}
/** Re-enable using the previously saved time. Returns error string or null. */
export async function reenableReminder(userId: string, authToken: string): Promise<string | null> {
const time = localStorage.getItem(REMINDER_TIME_KEY)
if (!time) return 'No reminder time saved.'
return enableReminder(time, userId, authToken)
}
/**
* Listen for foreground FCM messages and show a manual notification.
* Call once after the app mounts. Returns an unsubscribe function.
*/
export async function listenForegroundMessages(): Promise<() => void> {
const messaging = await messagingPromise
if (!messaging) return () => {}
const unsubscribe = onMessage(messaging, (payload) => {
const title = payload.notification?.title || 'Grateful Journal 🌱'
const body = payload.notification?.body || "You haven't written today yet."
if (Notification.permission === 'granted') {
new Notification(title, {
body,
icon: '/web-app-manifest-192x192.png',
tag: 'gj-daily-reminder',
})
}
})
return unsubscribe
}

View File

@@ -1,5 +1,6 @@
import { initializeApp } from 'firebase/app'
import { getAuth, GoogleAuthProvider } from 'firebase/auth'
import { getMessaging, isSupported } from 'firebase/messaging'
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
@@ -15,3 +16,6 @@ const app = initializeApp(firebaseConfig)
// Google Auth initialization
export const auth = getAuth(app)
export const googleProvider = new GoogleAuthProvider()
// FCM Messaging — resolves to null in unsupported browsers (e.g. Firefox, older Safari)
export const messagingPromise = isSupported().then((yes) => (yes ? getMessaging(app) : null))

View File

@@ -2,6 +2,7 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { listenForegroundMessages } from './hooks/useReminder'
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
@@ -9,6 +10,9 @@ if ('serviceWorker' in navigator) {
})
}
// Show FCM notifications when app is open in foreground
listenForegroundMessages()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />

View File

@@ -1,6 +1,14 @@
import { Link } from 'react-router-dom'
import { usePageMeta } from '../hooks/usePageMeta'
export default function AboutPage() {
usePageMeta({
title: 'About — Grateful Journal',
description: 'Learn about Grateful Journal — a free, end-to-end encrypted daily gratitude journal. No ads, no tracking, no social feed. Just you and your thoughts.',
canonical: 'https://gratefuljournal.online/about',
ogTitle: 'About Grateful Journal',
ogDescription: 'A free, private gratitude journal with end-to-end encryption. Learn how we built a distraction-free space for your daily reflection practice.',
})
return (
<div className="static-page">
<header className="static-page__header">

View File

@@ -4,8 +4,14 @@ import { useEffect, useState } from 'react'
import { GoogleSignInButton } from '../components/GoogleSignInButton'
import { TreeAnimation } from '../components/TreeAnimation'
import { PageLoader } from '../components/PageLoader'
import { usePageMeta } from '../hooks/usePageMeta'
export default function LoginPage() {
usePageMeta({
title: 'Grateful Journal — Your Private Gratitude Journal',
description: 'A private, end-to-end encrypted gratitude journal. No feeds, no noise — just you and your thoughts. Grow your gratitude one moment at a time.',
canonical: 'https://gratefuljournal.online/',
})
const { user, loading, signInWithGoogle, authError } = useAuth()
const navigate = useNavigate()
const [signingIn, setSigningIn] = useState(false)

View File

@@ -1,6 +1,14 @@
import { Link } from 'react-router-dom'
import { usePageMeta } from '../hooks/usePageMeta'
export default function PrivacyPage() {
usePageMeta({
title: 'Privacy Policy — Grateful Journal',
description: 'Grateful Journal\'s privacy policy. Your journal entries are end-to-end encrypted — we cannot read them. No ads, no tracking, no data selling.',
canonical: 'https://gratefuljournal.online/privacy',
ogTitle: 'Privacy Policy — Grateful Journal',
ogDescription: 'Your journal entries are end-to-end encrypted and private. We cannot read them, we don\'t sell your data, and we use no advertising cookies.',
})
return (
<div className="static-page">
<header className="static-page__header">

View File

@@ -7,6 +7,10 @@ import BottomNav from '../components/BottomNav'
import { useOnboardingTour, hasPendingTourStep, clearPendingTourStep } from '../hooks/useOnboardingTour'
import { PageLoader } from '../components/PageLoader'
import { usePWAInstall } from '../hooks/usePWAInstall'
import {
getSavedReminderTime, isReminderEnabled,
enableReminder, disableReminder, reenableReminder,
} from '../hooks/useReminder'
const MAX_PHOTO_SIZE = 200 // px — resize to 200x200
@@ -55,6 +59,14 @@ export default function SettingsPage() {
const { canInstall, isIOS, triggerInstall } = usePWAInstall()
const [installModal, setInstallModal] = useState<'ios' | 'chrome' | null>(null)
// Reminder state
const [reminderTime, setReminderTime] = useState<string | null>(() => getSavedReminderTime())
const [reminderEnabled, setReminderEnabled] = useState(() => isReminderEnabled())
const [showReminderModal, setShowReminderModal] = useState(false)
const [reminderPickedTime, setReminderPickedTime] = useState('08:00')
const [reminderError, setReminderError] = useState<string | null>(null)
const [reminderSaving, setReminderSaving] = useState(false)
// Edit profile modal state
const [showEditModal, setShowEditModal] = useState(false)
const [editName, setEditName] = useState('')
@@ -182,6 +194,56 @@ export default function SettingsPage() {
}
}
const handleOpenReminderModal = () => {
setReminderPickedTime(reminderTime || '08:00')
setReminderError(null)
setShowReminderModal(true)
}
const handleSaveReminder = async () => {
if (!user || !userId) return
setReminderSaving(true)
setReminderError(null)
const authToken = await user.getIdToken()
const error = await enableReminder(reminderPickedTime, userId, authToken)
setReminderSaving(false)
if (error) {
setReminderError(error)
} else {
setReminderTime(reminderPickedTime)
setReminderEnabled(true)
setShowReminderModal(false)
setMessage({ type: 'success', text: 'Reminder set!' })
setTimeout(() => setMessage(null), 2000)
}
}
const handleReminderToggle = async () => {
if (!user || !userId) return
if (!reminderTime) {
handleOpenReminderModal()
return
}
if (reminderEnabled) {
const authToken = await user.getIdToken()
await disableReminder(userId, authToken)
setReminderEnabled(false)
} else {
setReminderSaving(true)
const authToken = await user.getIdToken()
const error = await reenableReminder(userId, authToken)
setReminderSaving(false)
if (error) {
setReminderError(error)
setShowReminderModal(true)
} else {
setReminderEnabled(true)
setMessage({ type: 'success', text: 'Reminder enabled!' })
setTimeout(() => setMessage(null), 2000)
}
}
}
const handleSignOut = async () => {
try {
await signOut()
@@ -318,6 +380,40 @@ export default function SettingsPage() {
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
<div className="settings-divider"></div>
{/* Daily Reminder */}
<div className="settings-item">
<div className="settings-item-icon settings-item-icon-orange">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
</div>
<button
type="button"
className="settings-item-content"
onClick={handleOpenReminderModal}
style={{ background: 'none', border: 'none', cursor: 'pointer', textAlign: 'left', padding: 0 }}
>
<h4 className="settings-item-title">Daily Reminder</h4>
<p className="settings-item-subtitle">
{reminderTime
? (reminderEnabled ? `Reminds you at ${reminderTime}` : `Set to ${reminderTime} — paused`)
: 'Tap to set a daily reminder'}
</p>
</button>
<label className="settings-toggle" title={reminderEnabled ? 'Disable reminder' : 'Enable reminder'}>
<input
type="checkbox"
checked={reminderEnabled}
onChange={handleReminderToggle}
disabled={reminderSaving}
/>
<span className="settings-toggle-slider"></span>
</label>
</div>
</div>
</section>
@@ -622,6 +718,73 @@ export default function SettingsPage() {
</div>
)}
{/* Daily Reminder Modal */}
{showReminderModal && (
<div className="confirm-modal-overlay" onClick={() => !reminderSaving && setShowReminderModal(false)}>
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
<div style={{ fontSize: '2rem', textAlign: 'center', marginBottom: '0.5rem' }}>🔔</div>
<h3 className="confirm-modal-title">
{reminderTime ? 'Edit Reminder' : 'Set Daily Reminder'}
</h3>
<p className="confirm-modal-desc">
You'll get a notification at this time each day if you haven't written yet.
{' '}The reminder stays even if you log out and back in.
</p>
<label className="confirm-modal-label">Reminder time</label>
<input
type="time"
className="confirm-modal-input"
value={reminderPickedTime}
onChange={(e) => setReminderPickedTime(e.target.value)}
disabled={reminderSaving}
autoFocus
/>
{reminderError && (
<p style={{
color: 'var(--color-error, #ef4444)',
fontSize: '0.8rem',
marginTop: '0.5rem',
textAlign: 'center',
lineHeight: 1.4,
}}>
{reminderError}
</p>
)}
<p style={{
fontSize: '0.75rem',
color: 'var(--color-text-muted)',
marginTop: '0.75rem',
textAlign: 'center',
lineHeight: 1.5,
}}>
Works best when the app is installed on your home screen.
</p>
<div className="confirm-modal-actions" style={{ marginTop: '1rem' }}>
<button
type="button"
className="confirm-modal-cancel"
onClick={() => setShowReminderModal(false)}
disabled={reminderSaving}
>
Cancel
</button>
<button
type="button"
className="edit-modal-save"
onClick={handleSaveReminder}
disabled={reminderSaving || !reminderPickedTime}
>
{reminderSaving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
)}
<BottomNav />
</div>
)