feat: improve form usability with auto-focus, character limits, and unsaved changes warnings for better user experience
This commit is contained in:
@@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
- **Faster login redirects.** If you're not signed in and try to open a dashboard or settings page, you're redirected to login immediately instead of seeing a blank page first. Already-signed-in users who visit the login page are sent straight to the dashboard.
|
||||
- **Graceful error recovery.** If a page crashes, you now see a friendly error screen with a "Try again" button instead of a blank white page. Each section of the app has its own error message so you know exactly what went wrong.
|
||||
- **Security headers.** All pages now include clickjacking protection, MIME-sniffing prevention, a strict referrer policy, and HSTS. Browser APIs like camera and microphone are explicitly disabled.
|
||||
- **Better form experience.** Forms now auto-focus the first field when they open, text inputs enforce character limits with a visible counter when you're close, and the settings page warns you before navigating away with unsaved changes.
|
||||
|
||||
## [0.10.0-alpha] - 2026-02-21
|
||||
|
||||
|
||||
@@ -120,8 +120,13 @@ export default function CreateFunnelPage() {
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Signup Flow"
|
||||
autoFocus
|
||||
required
|
||||
maxLength={255}
|
||||
/>
|
||||
{name.length > 200 && (
|
||||
<span className={`text-xs tabular-nums mt-1 ${name.length > 240 ? 'text-amber-500' : 'text-neutral-400'}`}>{name.length}/255</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { getSite, updateSite, resetSiteData, deleteSite, type Site, type GeoDataLevel } from '@/lib/api/sites'
|
||||
import { listGoals, createGoal, updateGoal, deleteGoal, type Goal } from '@/lib/api/goals'
|
||||
@@ -13,6 +13,7 @@ import { PasswordInput } from '@ciphera-net/ui'
|
||||
import { Select, Modal, Button } from '@ciphera-net/ui'
|
||||
import { APP_URL } from '@/lib/api/client'
|
||||
import { generatePrivacySnippet } from '@/lib/utils/privacySnippet'
|
||||
import { useUnsavedChanges } from '@/lib/hooks/useUnsavedChanges'
|
||||
import { getSubscription, type SubscriptionDetails } from '@/lib/api/billing'
|
||||
import { getRetentionOptionsForPlan, formatRetentionMonths } from '@/lib/plans'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
@@ -86,6 +87,7 @@ export default function SiteSettingsPage() {
|
||||
const [editingGoal, setEditingGoal] = useState<Goal | null>(null)
|
||||
const [goalForm, setGoalForm] = useState({ name: '', event_name: '' })
|
||||
const [goalSaving, setGoalSaving] = useState(false)
|
||||
const initialFormRef = useRef<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
loadSite()
|
||||
@@ -146,6 +148,20 @@ export default function SiteSettingsPage() {
|
||||
// Data retention (default 6 = free-tier max; avoids flash-then-clamp for existing sites)
|
||||
data_retention_months: data.data_retention_months ?? 6
|
||||
})
|
||||
initialFormRef.current = JSON.stringify({
|
||||
name: data.name,
|
||||
timezone: data.timezone || 'UTC',
|
||||
is_public: data.is_public || false,
|
||||
excluded_paths: (data.excluded_paths || []).join('\n'),
|
||||
collect_page_paths: data.collect_page_paths ?? true,
|
||||
collect_referrers: data.collect_referrers ?? true,
|
||||
collect_device_info: data.collect_device_info ?? true,
|
||||
collect_geo_data: data.collect_geo_data || 'full',
|
||||
collect_screen_resolution: data.collect_screen_resolution ?? true,
|
||||
enable_performance_insights: data.enable_performance_insights ?? false,
|
||||
filter_bots: data.filter_bots ?? true,
|
||||
data_retention_months: data.data_retention_months ?? 6
|
||||
})
|
||||
if (data.has_password) {
|
||||
setIsPasswordEnabled(true)
|
||||
} else {
|
||||
@@ -264,6 +280,20 @@ export default function SiteSettingsPage() {
|
||||
data_retention_months: formData.data_retention_months
|
||||
})
|
||||
toast.success('Site updated successfully')
|
||||
initialFormRef.current = JSON.stringify({
|
||||
name: formData.name,
|
||||
timezone: formData.timezone,
|
||||
is_public: formData.is_public,
|
||||
excluded_paths: formData.excluded_paths,
|
||||
collect_page_paths: formData.collect_page_paths,
|
||||
collect_referrers: formData.collect_referrers,
|
||||
collect_device_info: formData.collect_device_info,
|
||||
collect_geo_data: formData.collect_geo_data,
|
||||
collect_screen_resolution: formData.collect_screen_resolution,
|
||||
enable_performance_insights: formData.enable_performance_insights,
|
||||
filter_bots: formData.filter_bots,
|
||||
data_retention_months: formData.data_retention_months
|
||||
})
|
||||
loadSite()
|
||||
} catch (error: any) {
|
||||
toast.error(getAuthErrorMessage(error) || 'Failed to save site settings')
|
||||
@@ -317,6 +347,23 @@ export default function SiteSettingsPage() {
|
||||
setTimeout(() => setSnippetCopied(false), 2000)
|
||||
}
|
||||
|
||||
const isFormDirty = initialFormRef.current !== '' && JSON.stringify({
|
||||
name: formData.name,
|
||||
timezone: formData.timezone,
|
||||
is_public: formData.is_public,
|
||||
excluded_paths: formData.excluded_paths,
|
||||
collect_page_paths: formData.collect_page_paths,
|
||||
collect_referrers: formData.collect_referrers,
|
||||
collect_device_info: formData.collect_device_info,
|
||||
collect_geo_data: formData.collect_geo_data,
|
||||
collect_screen_resolution: formData.collect_screen_resolution,
|
||||
enable_performance_insights: formData.enable_performance_insights,
|
||||
filter_bots: formData.filter_bots,
|
||||
data_retention_months: formData.data_retention_months
|
||||
}) !== initialFormRef.current
|
||||
|
||||
useUnsavedChanges(isFormDirty)
|
||||
|
||||
useEffect(() => {
|
||||
if (site?.domain) document.title = `Settings · ${site.domain} | Pulse`
|
||||
}, [site?.domain])
|
||||
@@ -454,11 +501,15 @@ export default function SiteSettingsPage() {
|
||||
type="text"
|
||||
id="name"
|
||||
required
|
||||
maxLength={255}
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-neutral-50/50 dark:bg-neutral-900/50 focus:bg-white dark:focus:bg-neutral-900
|
||||
focus:border-brand-orange focus:ring-4 focus:ring-brand-orange/10 outline-none transition-all duration-200 dark:text-white"
|
||||
/>
|
||||
{formData.name.length > 200 && (
|
||||
<span className={`text-xs tabular-nums ${formData.name.length > 240 ? 'text-amber-500' : 'text-neutral-400'}`}>{formData.name.length}/255</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
@@ -1062,6 +1113,7 @@ export default function SiteSettingsPage() {
|
||||
value={goalForm.name}
|
||||
onChange={(e) => setGoalForm({ ...goalForm, name: e.target.value })}
|
||||
placeholder="e.g. Signups"
|
||||
autoFocus
|
||||
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white"
|
||||
required
|
||||
/>
|
||||
@@ -1073,10 +1125,14 @@ export default function SiteSettingsPage() {
|
||||
value={goalForm.event_name}
|
||||
onChange={(e) => setGoalForm({ ...goalForm, event_name: e.target.value })}
|
||||
placeholder="e.g. signup_click (letters, numbers, underscores only)"
|
||||
maxLength={64}
|
||||
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white"
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">Only letters, numbers, and underscores; spaces become underscores. Invalid characters cannot be used. Max 64 characters after formatting.</p>
|
||||
<div className="flex justify-between mt-1">
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">Letters, numbers, and underscores only. Spaces become underscores.</p>
|
||||
<span className={`text-xs tabular-nums ${goalForm.event_name.length > 56 ? 'text-amber-500' : 'text-neutral-400'}`}>{goalForm.event_name.length}/64</span>
|
||||
</div>
|
||||
{editingGoal && goalForm.event_name.trim().toLowerCase().replace(/\s+/g, '_') !== editingGoal.event_name && (
|
||||
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">Changing event name does not reassign events already tracked under the previous name.</p>
|
||||
)}
|
||||
|
||||
@@ -937,8 +937,13 @@ function MonitorForm({
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="e.g. API, Website, CDN"
|
||||
autoFocus
|
||||
maxLength={255}
|
||||
className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:border-transparent text-sm"
|
||||
/>
|
||||
{formData.name.length > 200 && (
|
||||
<span className={`text-xs tabular-nums mt-1 ${formData.name.length > 240 ? 'text-amber-500' : 'text-neutral-400'}`}>{formData.name.length}/255</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* URL with protocol dropdown + domain prefix */}
|
||||
|
||||
@@ -191,6 +191,8 @@ export default function NewSitePage() {
|
||||
<Input
|
||||
id="name"
|
||||
required
|
||||
autoFocus
|
||||
maxLength={255}
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="My Website"
|
||||
@@ -204,6 +206,7 @@ export default function NewSitePage() {
|
||||
<Input
|
||||
id="domain"
|
||||
required
|
||||
maxLength={253}
|
||||
value={formData.domain}
|
||||
onChange={(e) => setFormData({ ...formData, domain: e.target.value.toLowerCase().trim() })}
|
||||
placeholder="example.com"
|
||||
|
||||
24
lib/hooks/useUnsavedChanges.ts
Normal file
24
lib/hooks/useUnsavedChanges.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useCallback } from 'react'
|
||||
|
||||
/**
|
||||
* Warns users with a browser prompt when they try to navigate away
|
||||
* or close the tab while there are unsaved form changes.
|
||||
*
|
||||
* @param isDirty - Whether the form has unsaved changes
|
||||
*/
|
||||
export function useUnsavedChanges(isDirty: boolean) {
|
||||
const handleBeforeUnload = useCallback(
|
||||
(e: BeforeUnloadEvent) => {
|
||||
if (!isDirty) return
|
||||
e.preventDefault()
|
||||
},
|
||||
[isDirty]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}, [handleBeforeUnload])
|
||||
}
|
||||
Reference in New Issue
Block a user