From da0366603eceae7e34e908607c2c7511e1e2d74f Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 22 Feb 2026 20:02:50 +0100 Subject: [PATCH] feat: improve form usability with auto-focus, character limits, and unsaved changes warnings for better user experience --- CHANGELOG.md | 1 + app/sites/[id]/funnels/new/page.tsx | 5 +++ app/sites/[id]/settings/page.tsx | 60 ++++++++++++++++++++++++++++- app/sites/[id]/uptime/page.tsx | 5 +++ app/sites/new/page.tsx | 3 ++ lib/hooks/useUnsavedChanges.ts | 24 ++++++++++++ 6 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 lib/hooks/useUnsavedChanges.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c924330..7cdf5a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/sites/[id]/funnels/new/page.tsx b/app/sites/[id]/funnels/new/page.tsx index 50da204..bc950a7 100644 --- a/app/sites/[id]/funnels/new/page.tsx +++ b/app/sites/[id]/funnels/new/page.tsx @@ -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 && ( + 240 ? 'text-amber-500' : 'text-neutral-400'}`}>{name.length}/255 + )}
@@ -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 /> -

Only letters, numbers, and underscores; spaces become underscores. Invalid characters cannot be used. Max 64 characters after formatting.

+
+

Letters, numbers, and underscores only. Spaces become underscores.

+ 56 ? 'text-amber-500' : 'text-neutral-400'}`}>{goalForm.event_name.length}/64 +
{editingGoal && goalForm.event_name.trim().toLowerCase().replace(/\s+/g, '_') !== editingGoal.event_name && (

Changing event name does not reassign events already tracked under the previous name.

)} diff --git a/app/sites/[id]/uptime/page.tsx b/app/sites/[id]/uptime/page.tsx index b00a060..deb9884 100644 --- a/app/sites/[id]/uptime/page.tsx +++ b/app/sites/[id]/uptime/page.tsx @@ -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 && ( + 240 ? 'text-amber-500' : 'text-neutral-400'}`}>{formData.name.length}/255 + )}
{/* URL with protocol dropdown + domain prefix */} diff --git a/app/sites/new/page.tsx b/app/sites/new/page.tsx index 0dd1829..3e3ae09 100644 --- a/app/sites/new/page.tsx +++ b/app/sites/new/page.tsx @@ -191,6 +191,8 @@ export default function NewSitePage() { setFormData({ ...formData, name: e.target.value })} placeholder="My Website" @@ -204,6 +206,7 @@ export default function NewSitePage() { setFormData({ ...formData, domain: e.target.value.toLowerCase().trim() })} placeholder="example.com" diff --git a/lib/hooks/useUnsavedChanges.ts b/lib/hooks/useUnsavedChanges.ts new file mode 100644 index 0000000..99016ba --- /dev/null +++ b/lib/hooks/useUnsavedChanges.ts @@ -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]) +}