added driverjs onboarding

This commit is contained in:
2026-03-16 11:52:33 +05:30
parent 07df39184e
commit ef52695bd9
10 changed files with 4917 additions and 4345 deletions

View File

@@ -932,6 +932,30 @@
cursor: not-allowed;
}
/* See Tutorial */
.settings-tutorial-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.875rem 1.125rem;
margin-bottom: 0.75rem;
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-primary, #22c55e);
background: var(--color-surface, #fff);
border: none;
border-radius: 14px;
cursor: pointer;
transition: background 0.2s;
font-family: "Sniglet", system-ui;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
}
.settings-tutorial-btn:hover {
background: var(--color-accent-light, #dcfce7);
}
/* Clear Data */
.settings-clear-btn {
width: 100%;
@@ -1655,6 +1679,15 @@
}
/* -- Settings buttons -- */
[data-theme="dark"] .settings-tutorial-btn {
background: #1a1a1a;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
[data-theme="dark"] .settings-tutorial-btn:hover {
background: rgba(74, 222, 128, 0.08);
}
[data-theme="dark"] .settings-clear-btn {
background: #1a1a1a;
color: #f87171;
@@ -1872,3 +1905,215 @@
border-color: rgba(74, 222, 128, 0.3);
box-shadow: 0 1px 8px rgba(74, 222, 128, 0.1);
}
/* ============================
WELCOME MODAL
============================ */
.welcome-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1.5rem;
animation: welcome-fade-in 0.3s ease;
}
@keyframes welcome-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes welcome-slide-up {
from {
opacity: 0;
transform: translateY(24px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.welcome-modal {
background: var(--color-surface, #ffffff);
border-radius: 20px;
padding: 2.5rem 2rem 2rem;
max-width: 400px;
width: 100%;
text-align: center;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
animation: welcome-slide-up 0.35s ease;
}
.welcome-modal-icon {
display: flex;
align-items: center;
justify-content: center;
width: 72px;
height: 72px;
margin: 0 auto 1.25rem;
background: var(--color-accent-light, #dcfce7);
border-radius: 50%;
color: var(--color-primary, #22c55e);
}
.welcome-modal-title {
font-family: "Sniglet", system-ui;
font-size: 1.375rem;
font-weight: 700;
color: var(--color-text, #1a1a1a);
margin: 0 0 0.75rem;
}
.welcome-modal-text {
font-family: "Sniglet", system-ui;
font-size: 0.9rem;
line-height: 1.6;
color: var(--color-text-muted, #6b7280);
margin: 0 0 1.75rem;
}
.welcome-modal-btn {
font-family: "Sniglet", system-ui;
font-size: 1rem;
font-weight: 700;
color: #fff;
background: var(--color-primary, #22c55e);
border: none;
border-radius: 14px;
padding: 0.875rem 2rem;
width: 100%;
cursor: pointer;
transition:
background 0.15s,
transform 0.15s;
}
.welcome-modal-btn:hover {
background: #16a34a;
transform: translateY(-1px);
}
.welcome-modal-btn:active {
transform: translateY(0);
}
.welcome-modal-skip {
font-family: "Sniglet", system-ui;
font-size: 0.8125rem;
color: var(--color-text-muted, #6b7280);
background: none;
border: none;
cursor: pointer;
margin-top: 0.75rem;
padding: 0.5rem 1rem;
transition: color 0.15s;
}
.welcome-modal-skip:hover {
color: var(--color-text, #1a1a1a);
}
/* ============================
DRIVER.JS TOUR CUSTOMIZATION
============================ */
.gj-tour-popover {
font-family: "Sniglet", system-ui !important;
border-radius: 16px !important;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2) !important;
}
.gj-tour-popover .driver-popover-title {
font-family: "Sniglet", system-ui !important;
font-size: 1.05rem !important;
font-weight: 700 !important;
color: var(--color-text, #1a1a1a) !important;
}
.gj-tour-popover .driver-popover-description {
font-family: "Sniglet", system-ui !important;
font-size: 0.85rem !important;
line-height: 1.55 !important;
color: var(--color-text-muted, #6b7280) !important;
}
.gj-tour-popover .driver-popover-progress-text {
font-family: "Sniglet", system-ui !important;
font-size: 0.75rem !important;
color: var(--color-text-muted, #6b7280) !important;
}
.gj-tour-popover .driver-popover-navigation-btns button {
font-family: "Sniglet", system-ui !important;
border-radius: 10px !important;
font-size: 0.825rem !important;
padding: 0.4rem 1rem !important;
font-weight: 600 !important;
}
.gj-tour-popover .driver-popover-next-btn {
background: var(--color-primary, #22c55e) !important;
color: #fff !important;
border: none !important;
text-shadow: none !important;
}
.gj-tour-popover .driver-popover-prev-btn {
color: var(--color-text-muted, #6b7280) !important;
border: 1px solid var(--color-border, #d4e8d4) !important;
background: transparent !important;
}
.gj-tour-popover .driver-popover-close-btn {
color: var(--color-text-muted, #6b7280) !important;
outline: none !important;
box-shadow: none !important;
border: none !important;
background: transparent !important;
}
.gj-tour-popover .driver-popover-close-btn:focus,
.gj-tour-popover .driver-popover-close-btn:focus-visible {
outline: none !important;
box-shadow: none !important;
border: none !important;
}
/* -- Dark theme: welcome modal -- */
[data-theme="dark"] .welcome-modal {
background: #1a1a1a;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
/* -- Dark theme: driver.js -- */
[data-theme="dark"] .gj-tour-popover {
background: #1a1a1a !important;
border: 1px solid rgba(74, 222, 128, 0.12) !important;
}
[data-theme="dark"] .gj-tour-popover .driver-popover-title {
color: #e8f5e8 !important;
}
[data-theme="dark"] .gj-tour-popover .driver-popover-description {
color: #7a8a7a !important;
}
[data-theme="dark"] .gj-tour-popover .driver-popover-prev-btn {
border-color: rgba(74, 222, 128, 0.15) !important;
color: #7a8a7a !important;
}
[data-theme="dark"] .gj-tour-popover .driver-popover-close-btn {
color: #7a8a7a !important;
outline: none !important;
box-shadow: none !important;
border: none !important;
}

View File

@@ -29,6 +29,7 @@ export default function BottomNav() {
{/* History */}
<button
type="button"
id="tour-nav-history"
className={`bottom-nav-btn ${isActive('/history') ? 'bottom-nav-btn-active' : ''}`}
onClick={() => navigate('/history')}
aria-label="History"
@@ -44,6 +45,7 @@ export default function BottomNav() {
{/* Settings */}
<button
type="button"
id="tour-nav-settings"
className={`bottom-nav-btn ${isActive('/settings') ? 'bottom-nav-btn-active' : ''}`}
onClick={() => navigate('/settings')}
aria-label="Settings"

View File

@@ -0,0 +1,30 @@
interface WelcomeModalProps {
onStart: () => void
onSkip: () => void
}
export default function WelcomeModal({ onStart, onSkip }: WelcomeModalProps) {
return (
<div className="welcome-modal-overlay" onClick={onSkip}>
<div className="welcome-modal" onClick={(e) => e.stopPropagation()}>
<div className="welcome-modal-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
</svg>
</div>
<h2 className="welcome-modal-title">Welcome to Grateful Journal</h2>
<p className="welcome-modal-text">
A private, peaceful space to capture what you're grateful for every day.
Your entries are end-to-end encrypted, so only you can read them.
No feeds, no noise just you and your thoughts.
</p>
<button className="welcome-modal-btn" onClick={onStart}>
Start Your Journey
</button>
<button className="welcome-modal-skip" onClick={onSkip}>
Skip tour
</button>
</div>
</div>
)
}

View File

@@ -163,6 +163,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
async function signOut() {
// Clear secret key from memory
setSecretKey(null)
// Reset onboarding so tour shows again on next login
localStorage.removeItem('gj-onboarding-done')
localStorage.removeItem('gj-tour-pending-step')
// Keep device key and encrypted key for next login
// Do NOT clear localStorage or IndexedDB
await firebaseSignOut(auth)

View File

@@ -0,0 +1,216 @@
import { useCallback, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { driver, type DriveStep } from 'driver.js'
import 'driver.js/dist/driver.css'
const ONBOARDING_KEY = 'gj-onboarding-done'
const TOUR_PENDING_KEY = 'gj-tour-pending-step'
export function hasSeenOnboarding(): boolean {
return localStorage.getItem(ONBOARDING_KEY) === 'true'
}
export function markOnboardingDone(): void {
localStorage.setItem(ONBOARDING_KEY, 'true')
}
export function hasPendingTourStep(): string | null {
return localStorage.getItem(TOUR_PENDING_KEY)
}
export function clearPendingTourStep(): void {
localStorage.removeItem(TOUR_PENDING_KEY)
}
function driverDefaults() {
return {
showProgress: true,
animate: true,
allowClose: true,
overlayColor: 'rgba(0, 0, 0, 0.6)',
stagePadding: 8,
stageRadius: 12,
popoverClass: 'gj-tour-popover',
nextBtnText: 'Next',
prevBtnText: 'Back',
doneBtnText: 'Got it!',
progressText: '{{current}} of {{total}}',
} as const
}
function getHomeSteps(isMobile: boolean): DriveStep[] {
return [
{
element: '#tour-title-input',
popover: {
title: 'Give it a Title',
description: 'Start by naming your gratitude entry. A short title helps you find it later.',
side: 'bottom',
align: 'center',
},
},
{
element: '#tour-content-textarea',
popover: {
title: 'Write Your Thoughts',
description: 'Pour out what you\'re grateful for today. There\'s no right or wrong — just write from the heart.',
side: isMobile ? 'top' : 'bottom',
align: 'center',
},
},
{
element: '#tour-save-btn',
popover: {
title: 'Save Your Entry',
description: 'Hit save and your entry is securely encrypted and stored. Only you can read it.',
side: 'top',
align: 'center',
},
},
{
element: '#tour-nav-history',
popover: {
title: 'View Your History',
description: 'This takes you to the History page. Let\'s go there next!',
side: isMobile ? 'top' : 'right',
align: 'center',
},
},
]
}
function getHistorySteps(isMobile: boolean): DriveStep[] {
return [
{
element: '#tour-calendar',
popover: {
title: 'Your Calendar',
description: 'Green dots mark days you wrote entries. Navigate between months using the arrows.',
side: isMobile ? 'bottom' : 'right',
align: 'center',
},
},
{
element: '#tour-entries-list',
popover: {
title: 'Your Past Entries',
description: 'Tap any date on the calendar to see entries from that day. Tap an entry card to read the full content.',
side: isMobile ? 'top' : 'left',
align: 'center',
},
},
{
element: '#tour-nav-settings',
popover: {
title: 'Your Settings',
description: 'Let\'s check out your settings next!',
side: isMobile ? 'top' : 'right',
align: 'center',
},
},
]
}
function getSettingsSteps(isMobile: boolean): DriveStep[] {
return [
{
element: '#tour-theme-switcher',
popover: {
title: 'Pick Your Theme',
description: 'Switch between Light and Dark mode. Your choice is saved automatically.',
side: isMobile ? 'top' : 'bottom',
align: 'center',
},
},
]
}
export function useOnboardingTour() {
const navigate = useNavigate()
const driverRef = useRef<ReturnType<typeof driver> | null>(null)
const startTour = useCallback(() => {
const isMobile = window.innerWidth < 860
const driverObj = driver({
...driverDefaults(),
onDestroyStarted: () => {
markOnboardingDone()
clearPendingTourStep()
driverObj.destroy()
},
onNextClick: () => {
const activeIndex = driverObj.getActiveIndex()
const steps = driverObj.getConfig().steps || []
// Last home step → navigate to /history
if (activeIndex === steps.length - 1) {
localStorage.setItem(TOUR_PENDING_KEY, 'history')
driverObj.destroy()
navigate('/history')
return
}
driverObj.moveNext()
},
steps: getHomeSteps(isMobile),
})
driverRef.current = driverObj
setTimeout(() => driverObj.drive(), 150)
}, [navigate])
const continueTourOnHistory = useCallback(() => {
const isMobile = window.innerWidth < 860
const driverObj = driver({
...driverDefaults(),
onDestroyStarted: () => {
markOnboardingDone()
clearPendingTourStep()
driverObj.destroy()
},
onNextClick: () => {
const activeIndex = driverObj.getActiveIndex()
const steps = driverObj.getConfig().steps || []
// Last history step → navigate to /settings
if (activeIndex === steps.length - 1) {
localStorage.setItem(TOUR_PENDING_KEY, 'settings')
driverObj.destroy()
navigate('/settings')
return
}
driverObj.moveNext()
},
steps: getHistorySteps(isMobile),
})
driverRef.current = driverObj
setTimeout(() => driverObj.drive(), 300)
}, [navigate])
const continueTourOnSettings = useCallback(() => {
const isMobile = window.innerWidth < 860
const driverObj = driver({
...driverDefaults(),
onDestroyStarted: () => {
markOnboardingDone()
clearPendingTourStep()
driverObj.destroy()
},
onDestroyed: () => {
markOnboardingDone()
clearPendingTourStep()
},
steps: getSettingsSteps(isMobile),
})
driverRef.current = driverObj
setTimeout(() => driverObj.drive(), 300)
}, [])
return { startTour, continueTourOnHistory, continueTourOnSettings }
}

View File

@@ -4,6 +4,7 @@ import { getUserEntries, type JournalEntry } from '../lib/api'
import { decryptEntry } from '../lib/crypto'
import { formatIST, getISTDateComponents } from '../lib/timezone'
import BottomNav from '../components/BottomNav'
import { useOnboardingTour, hasPendingTourStep, clearPendingTourStep } from '../hooks/useOnboardingTour'
interface DecryptedEntry extends JournalEntry {
decryptedTitle?: string
@@ -19,6 +20,16 @@ export default function HistoryPage() {
const [loadingEntries, setLoadingEntries] = useState(false)
const [selectedEntry, setSelectedEntry] = useState<DecryptedEntry | null>(null)
const { continueTourOnHistory } = useOnboardingTour()
// Continue onboarding tour if navigated here from the home page tour
useEffect(() => {
if (hasPendingTourStep() === 'history') {
clearPendingTourStep()
continueTourOnHistory()
}
}, [])
// Fetch entries on mount and when userId changes
useEffect(() => {
if (!user || !userId) return
@@ -189,7 +200,7 @@ export default function HistoryPage() {
</header>
<main className="history-container">
<div className="calendar-card">
<div id="tour-calendar" className="calendar-card">
<div className="calendar-header">
<h2 className="calendar-month">{monthName}</h2>
<div className="calendar-nav">
@@ -239,7 +250,7 @@ export default function HistoryPage() {
</div>
</div>
<section className="recent-entries">
<section id="tour-entries-list" className="recent-entries">
<h3 className="recent-entries-title">
{selectedDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).toUpperCase()}
</h3>

View File

@@ -1,9 +1,11 @@
import { useAuth } from '../contexts/AuthContext'
import { Link } from 'react-router-dom'
import { useState, useRef } from 'react'
import { useState, useRef, useEffect } from 'react'
import { createEntry } from '../lib/api'
import { encryptEntry } from '../lib/crypto'
import BottomNav from '../components/BottomNav'
import WelcomeModal from '../components/WelcomeModal'
import { useOnboardingTour, hasSeenOnboarding, markOnboardingDone } from '../hooks/useOnboardingTour'
export default function HomePage() {
const { user, userId, secretKey, loading } = useAuth()
@@ -11,10 +13,30 @@ export default function HomePage() {
const [title, setTitle] = useState('')
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const [showWelcome, setShowWelcome] = useState(false)
const titleInputRef = useRef<HTMLInputElement>(null)
const contentTextareaRef = useRef<HTMLTextAreaElement>(null)
const { startTour } = useOnboardingTour()
// Check if onboarding should be shown after login
useEffect(() => {
if (!loading && user && userId && !hasSeenOnboarding()) {
setShowWelcome(true)
}
}, [loading, user, userId])
const handleStartTour = () => {
setShowWelcome(false)
startTour()
}
const handleSkipTour = () => {
setShowWelcome(false)
markOnboardingDone()
}
if (loading) {
return (
<div className="home-page" style={{ alignItems: 'center', justifyContent: 'center' }}>
@@ -106,6 +128,9 @@ export default function HomePage() {
return (
<div className="home-page">
{showWelcome && (
<WelcomeModal onStart={handleStartTour} onSkip={handleSkipTour} />
)}
<main className="journal-container">
<div className="journal-card">
<div className="journal-date">{dateString}</div>
@@ -115,6 +140,7 @@ export default function HomePage() {
<div className="journal-writing-area">
<input
type="text"
id="tour-title-input"
className="journal-title-input"
placeholder="Title your thoughts..."
value={title}
@@ -125,6 +151,7 @@ export default function HomePage() {
disabled={saving}
/>
<textarea
id="tour-content-textarea"
className="journal-entry-textarea"
placeholder="Start writing your entry here..."
value={entry}
@@ -151,6 +178,7 @@ export default function HomePage() {
<div style={{ marginTop: '1.5rem', display: 'flex', gap: '0.75rem' }}>
<button
id="tour-save-btn"
className="journal-write-btn"
onClick={handleWrite}
disabled={saving || !title.trim() || !entry.trim()}

View File

@@ -2,7 +2,9 @@ import { useState, useEffect, useCallback } from 'react'
import { useAuth } from '../contexts/AuthContext'
import { deleteUser as deleteUserApi } from '../lib/api'
import { clearDeviceKey, clearEncryptedSecretKey } from '../lib/crypto'
import { useNavigate } from 'react-router-dom'
import BottomNav from '../components/BottomNav'
import { useOnboardingTour, hasPendingTourStep, clearPendingTourStep } from '../hooks/useOnboardingTour'
export default function SettingsPage() {
const { user, userId, signOut, loading } = useAuth()
@@ -18,6 +20,23 @@ export default function SettingsPage() {
const [confirmEmail, setConfirmEmail] = useState('')
const [deleting, setDeleting] = useState(false)
const { continueTourOnSettings } = useOnboardingTour()
const navigate = useNavigate()
// Continue onboarding tour if navigated here from the history page tour
useEffect(() => {
if (hasPendingTourStep() === 'settings') {
clearPendingTourStep()
continueTourOnSettings()
}
}, [])
const handleSeeTutorial = () => {
localStorage.removeItem('gj-onboarding-done')
localStorage.removeItem('gj-tour-pending-step')
navigate('/')
}
const displayName = user?.displayName || 'User'
// Apply theme to DOM
@@ -203,7 +222,7 @@ export default function SettingsPage() {
<div className="settings-divider"></div>
*/}
<div className="settings-item">
<div id="tour-theme-switcher" className="settings-item">
<div className="settings-item-icon settings-item-icon-blue">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="13.5" cy="6.5" r=".5"></circle>
@@ -249,6 +268,16 @@ export default function SettingsPage() {
</div>
)}
{/* See Tutorial */}
<button type="button" className="settings-tutorial-btn" onClick={handleSeeTutorial}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
<span>See Tutorial</span>
</button>
{/* Clear Data */}
<button type="button" className="settings-clear-btn" onClick={handleClearData}>
<span>Clear All Data</span>