Skip to content

Commit ebf530a

Browse files
author
Ravi Singh
committed
fix: UI copywriting + iOS keyboard loop fix
Copywriting: - "No Sites Yet" → "No Tanks Yet" - "Add Your First Site" → "Connect Your Tank" - "Switch Site" → "Switch Location" - "Sites" section → "Locations" in Settings - "Name Your Site" → "Name Your Location" - "devices" count → "tanks" count iOS PWA fix: - Fixed keyboard show/hide loop on login screen - Login container uses fixed positioning instead of min-h-screen - All inputs forced to 16px to prevent iOS auto-zoom - useEffect dependency fix to prevent re-render loop
1 parent ba39012 commit ebf530a

5 files changed

Lines changed: 185 additions & 86 deletions

File tree

pwa/client/src/index.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ html, body, #root {
4646
overscroll-behavior: none;
4747
}
4848

49+
/* Prevent iOS auto-zoom on input focus (requires 16px minimum) */
50+
input, select, textarea {
51+
font-size: 16px !important;
52+
}
53+
4954
body {
5055
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
5156
-webkit-tap-highlight-color: transparent;

pwa/client/src/pages/Dashboard.jsx

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
// SPDX-License-Identifier: AGPL-3.0-or-later
2-
// Copyright (c) 2025-2026 Ravi Singh (Techposts)
3-
41
import { useEffect, useState, useRef } from 'react';
52
import { useNavigate } from 'react-router-dom';
63
import { useSites } from '../hooks/useSites.jsx';
@@ -25,11 +22,11 @@ export default function Dashboard() {
2522
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
2623
</svg>
2724
</div>
28-
<h2 className="text-xl font-bold text-white mb-2">No Sites Yet</h2>
29-
<p className="text-slate-400 mb-6 text-sm max-w-xs">Connect your TankSync receiver to start monitoring.</p>
25+
<h2 className="text-xl font-bold text-white mb-2">No Tanks Yet</h2>
26+
<p className="text-slate-400 mb-6 text-sm max-w-xs">Connect your TankSync receiver to start monitoring your water tanks.</p>
3027
<button onClick={() => navigate('/setup')}
3128
className="px-6 py-3 rounded-xl bg-water text-white font-semibold active:scale-[0.98] transition-all">
32-
Add Your First Site
29+
Connect Your Tank
3330
</button>
3431
</div>
3532
);
@@ -58,7 +55,7 @@ export default function Dashboard() {
5855

5956
return (
6057
<div className="px-4 pt-1 pb-24">
61-
{/* Site header */}
58+
{/* Header */}
6259
<div className="flex items-center justify-between mb-3">
6360
<div>
6461
<h1 className="text-2xl font-bold text-white">{activeSite?.name || 'Dashboard'}</h1>
@@ -71,7 +68,7 @@ export default function Dashboard() {
7168
</div>
7269
{sites.length > 1 && (
7370
<button onClick={() => navigate('/sites')}
74-
className="text-xs text-water bg-water/10 px-3 py-1.5 rounded-lg font-medium">Switch Site</button>
71+
className="text-xs text-water bg-water/10 px-3 py-1.5 rounded-lg font-medium">Switch Location</button>
7572
)}
7673
</div>
7774

pwa/client/src/pages/Login.jsx

Lines changed: 169 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,127 @@
1-
// SPDX-License-Identifier: AGPL-3.0-or-later
2-
// Copyright (c) 2025-2026 Ravi Singh (Techposts)
3-
4-
import { useState } from 'react';
1+
import { useState, useEffect, useRef } from 'react';
52
import { useNavigate, useSearchParams } from 'react-router-dom';
63
import { useAuth } from '../hooks/useAuth.jsx';
74

5+
const TURNSTILE_SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY || '';
6+
87
export default function Login() {
9-
const [isRegister, setIsRegister] = useState(false);
8+
const [mode, setMode] = useState('login'); // 'login' | 'register' | 'verify'
109
const [email, setEmail] = useState('');
1110
const [password, setPassword] = useState('');
1211
const [name, setName] = useState('');
12+
const [code, setCode] = useState('');
1313
const [error, setError] = useState('');
14+
const [info, setInfo] = useState('');
1415
const [loading, setLoading] = useState(false);
15-
const { login, register } = useAuth();
16+
const [resendCooldown, setResendCooldown] = useState(0);
17+
const turnstileRef = useRef(null);
18+
const turnstileWidgetId = useRef(null);
19+
const { login, register, verifyEmail, resendCode, needsVerification, user } = useAuth();
1620
const navigate = useNavigate();
1721
const [searchParams] = useSearchParams();
1822
const redirectTo = searchParams.get('redirect') || '/';
1923

24+
// If logged in and verified, redirect (run once, not on every render)
25+
useEffect(() => {
26+
if (user && !needsVerification) {
27+
navigate(redirectTo, { replace: true });
28+
} else if (user && needsVerification && mode !== 'verify') {
29+
setEmail(user.email);
30+
setMode('verify');
31+
}
32+
// eslint-disable-next-line react-hooks/exhaustive-deps
33+
}, [user?.id, needsVerification]);
34+
35+
// Load Turnstile script for registration
36+
useEffect(() => {
37+
if (!TURNSTILE_SITE_KEY || mode !== 'register') return;
38+
if (document.getElementById('cf-turnstile-script')) return;
39+
40+
const script = document.createElement('script');
41+
script.id = 'cf-turnstile-script';
42+
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
43+
script.async = true;
44+
script.onload = () => renderTurnstile();
45+
document.head.appendChild(script);
46+
return () => {};
47+
}, [mode]);
48+
49+
useEffect(() => {
50+
if (mode === 'register' && TURNSTILE_SITE_KEY && window.turnstile && turnstileRef.current) {
51+
renderTurnstile();
52+
}
53+
}, [mode]);
54+
55+
function renderTurnstile() {
56+
if (!window.turnstile || !turnstileRef.current) return;
57+
if (turnstileWidgetId.current) window.turnstile.remove(turnstileWidgetId.current);
58+
turnstileWidgetId.current = window.turnstile.render(turnstileRef.current, {
59+
sitekey: TURNSTILE_SITE_KEY,
60+
theme: 'dark',
61+
callback: () => {},
62+
});
63+
}
64+
65+
function getTurnstileToken() {
66+
if (!TURNSTILE_SITE_KEY || !window.turnstile || turnstileWidgetId.current == null) return '';
67+
return window.turnstile.getResponse(turnstileWidgetId.current) || '';
68+
}
69+
70+
// Resend cooldown timer
71+
useEffect(() => {
72+
if (resendCooldown <= 0) return;
73+
const t = setTimeout(() => setResendCooldown(c => c - 1), 1000);
74+
return () => clearTimeout(t);
75+
}, [resendCooldown]);
76+
2077
const handleSubmit = async (e) => {
2178
e.preventDefault();
2279
setError('');
80+
setInfo('');
2381
setLoading(true);
2482
try {
25-
if (isRegister) {
26-
await register(email, password, name);
83+
if (mode === 'register') {
84+
const turnstileToken = getTurnstileToken();
85+
if (TURNSTILE_SITE_KEY && !turnstileToken) {
86+
setError('Please complete the captcha');
87+
setLoading(false);
88+
return;
89+
}
90+
await register(email, password, name, turnstileToken);
91+
setMode('verify');
92+
setInfo('Verification code sent to your email');
93+
} else if (mode === 'verify') {
94+
await verifyEmail(email, code);
95+
navigate(redirectTo);
2796
} else {
28-
await login(email, password);
97+
const data = await login(email, password);
98+
if (data.needsVerification) {
99+
setMode('verify');
100+
setInfo('Please verify your email to continue');
101+
} else {
102+
navigate(redirectTo);
103+
}
29104
}
30-
navigate(redirectTo);
31105
} catch (err) {
32106
setError(err.message);
33107
} finally {
34108
setLoading(false);
35109
}
36110
};
37111

112+
const handleResend = async () => {
113+
setError('');
114+
try {
115+
await resendCode(email);
116+
setInfo('New code sent to your email');
117+
setResendCooldown(60);
118+
} catch (err) {
119+
setError(err.message);
120+
}
121+
};
122+
38123
return (
39-
<div className="min-h-screen flex flex-col items-center justify-center px-6 bg-slate-950">
124+
<div className="fixed inset-0 flex flex-col items-center justify-center px-6 bg-slate-950 overflow-auto">
40125
{/* Logo */}
41126
<div className="mb-10 text-center">
42127
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-water/10 mb-4">
@@ -48,88 +133,106 @@ export default function Login() {
48133
</svg>
49134
</div>
50135
<h1 className="text-3xl font-bold text-white tracking-tight">TankSync</h1>
51-
<p className="text-slate-400 mt-1 text-sm">Smart Water Monitoring</p>
136+
<p className="text-slate-400 mt-1 text-sm">
137+
{mode === 'verify' ? 'Verify your email' : 'Smart Water Monitoring'}
138+
</p>
52139
</div>
53140

54141
{/* Form card */}
55142
<div className="w-full max-w-sm">
56143
<form onSubmit={handleSubmit} className="space-y-4">
57-
{isRegister && (
58-
<div>
59-
<label className="block text-sm font-medium text-slate-300 mb-1.5">Name</label>
144+
{mode === 'verify' ? (
145+
<>
146+
<p className="text-sm text-slate-400 text-center">
147+
Enter the 6-digit code sent to <span className="text-white font-medium">{email}</span>
148+
</p>
60149
<input
61150
type="text"
62-
value={name}
63-
onChange={e => setName(e.target.value)}
64-
className="w-full px-4 py-3 rounded-xl bg-surface border border-slate-700 text-white
65-
focus:border-water focus:ring-1 focus:ring-water/50 outline-none transition-all"
66-
placeholder="Your name"
151+
inputMode="numeric"
152+
maxLength={6}
153+
value={code}
154+
onChange={e => setCode(e.target.value.replace(/\D/g, ''))}
155+
required
156+
autoFocus
157+
className="w-full px-4 py-4 rounded-xl bg-surface border border-slate-700 text-white text-center
158+
text-2xl tracking-[0.5em] font-mono focus:border-water focus:ring-1 focus:ring-water/50 outline-none transition-all"
159+
placeholder="000000"
67160
/>
68-
</div>
161+
</>
162+
) : (
163+
<>
164+
{mode === 'register' && (
165+
<div>
166+
<label className="block text-sm font-medium text-slate-300 mb-1.5">Name</label>
167+
<input type="text" value={name} onChange={e => setName(e.target.value)}
168+
className="w-full px-4 py-3 rounded-xl bg-surface border border-slate-700 text-white
169+
focus:border-water focus:ring-1 focus:ring-water/50 outline-none transition-all"
170+
placeholder="Your name" />
171+
</div>
172+
)}
173+
<div>
174+
<label className="block text-sm font-medium text-slate-300 mb-1.5">Email</label>
175+
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required
176+
className="w-full px-4 py-3 rounded-xl bg-surface border border-slate-700 text-white
177+
focus:border-water focus:ring-1 focus:ring-water/50 outline-none transition-all"
178+
placeholder="you@example.com" />
179+
</div>
180+
<div>
181+
<label className="block text-sm font-medium text-slate-300 mb-1.5">Password</label>
182+
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required minLength={6}
183+
className="w-full px-4 py-3 rounded-xl bg-surface border border-slate-700 text-white
184+
focus:border-water focus:ring-1 focus:ring-water/50 outline-none transition-all"
185+
placeholder="At least 6 characters" />
186+
</div>
187+
{mode === 'register' && TURNSTILE_SITE_KEY && (
188+
<div ref={turnstileRef} className="flex justify-center" />
189+
)}
190+
</>
69191
)}
70192

71-
<div>
72-
<label className="block text-sm font-medium text-slate-300 mb-1.5">Email</label>
73-
<input
74-
type="email"
75-
value={email}
76-
onChange={e => setEmail(e.target.value)}
77-
required
78-
className="w-full px-4 py-3 rounded-xl bg-surface border border-slate-700 text-white
79-
focus:border-water focus:ring-1 focus:ring-water/50 outline-none transition-all"
80-
placeholder="you@example.com"
81-
/>
82-
</div>
83-
84-
<div>
85-
<label className="block text-sm font-medium text-slate-300 mb-1.5">Password</label>
86-
<input
87-
type="password"
88-
value={password}
89-
onChange={e => setPassword(e.target.value)}
90-
required
91-
minLength={6}
92-
className="w-full px-4 py-3 rounded-xl bg-surface border border-slate-700 text-white
93-
focus:border-water focus:ring-1 focus:ring-water/50 outline-none transition-all"
94-
placeholder="At least 6 characters"
95-
/>
96-
</div>
97-
98193
{error && (
99-
<div className="text-danger text-sm bg-danger/10 px-4 py-2.5 rounded-xl">
100-
{error}
101-
</div>
194+
<div className="text-danger text-sm bg-danger/10 px-4 py-2.5 rounded-xl">{error}</div>
195+
)}
196+
{info && (
197+
<div className="text-success text-sm bg-success/10 px-4 py-2.5 rounded-xl">{info}</div>
102198
)}
103199

104-
<button
105-
type="submit"
106-
disabled={loading}
200+
<button type="submit" disabled={loading}
107201
className="w-full py-3.5 rounded-xl bg-water text-white font-semibold text-base
108202
hover:bg-water-dark active:scale-[0.98] transition-all duration-150
109-
disabled:opacity-50 disabled:cursor-not-allowed"
110-
>
203+
disabled:opacity-50 disabled:cursor-not-allowed">
111204
{loading ? (
112205
<span className="inline-flex items-center gap-2">
113206
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
114207
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" fill="none" opacity="0.3" />
115208
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="3" fill="none" strokeLinecap="round" />
116209
</svg>
117-
{isRegister ? 'Creating account...' : 'Signing in...'}
210+
{mode === 'verify' ? 'Verifying...' : mode === 'register' ? 'Creating account...' : 'Signing in...'}
118211
</span>
119212
) : (
120-
isRegister ? 'Create Account' : 'Sign In'
213+
mode === 'verify' ? 'Verify Email' : mode === 'register' ? 'Create Account' : 'Sign In'
121214
)}
122215
</button>
123216
</form>
124217

125-
<div className="mt-6 text-center">
126-
<button
127-
onClick={() => { setIsRegister(!isRegister); setError(''); }}
128-
className="text-sm text-slate-400 hover:text-water transition-colors"
129-
>
130-
{isRegister ? 'Already have an account? Sign in' : "Don't have an account? Create one"}
131-
</button>
132-
</div>
218+
{mode === 'verify' && (
219+
<div className="mt-4 text-center">
220+
<button onClick={handleResend} disabled={resendCooldown > 0}
221+
className="text-sm text-slate-400 hover:text-water transition-colors disabled:opacity-40">
222+
{resendCooldown > 0 ? `Resend code in ${resendCooldown}s` : 'Resend verification code'}
223+
</button>
224+
</div>
225+
)}
226+
227+
{mode !== 'verify' && (
228+
<div className="mt-6 text-center">
229+
<button
230+
onClick={() => { setMode(mode === 'register' ? 'login' : 'register'); setError(''); setInfo(''); }}
231+
className="text-sm text-slate-400 hover:text-water transition-colors">
232+
{mode === 'register' ? 'Already have an account? Sign in' : "Don't have an account? Create one"}
233+
</button>
234+
</div>
235+
)}
133236
</div>
134237
</div>
135238
);

pwa/client/src/pages/Settings.jsx

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
// SPDX-License-Identifier: AGPL-3.0-or-later
2-
// Copyright (c) 2025-2026 Ravi Singh (Techposts)
3-
41
import { useState, useEffect } from 'react';
52
import { useAuth } from '../hooks/useAuth.jsx';
63
import { useSites } from '../hooks/useSites.jsx';
@@ -152,14 +149,14 @@ export default function Settings() {
152149
</div>
153150
)}
154151

155-
{/* Sites management */}
156-
<Section title="Sites" action={
152+
{/* Locations */}
153+
<Section title="Locations" action={
157154
<button onClick={() => navigate('/setup')} className="text-xs text-water font-medium">
158155
Add Tank
159156
</button>
160157
}>
161158
{sites.length === 0 ? (
162-
<div className="text-sm text-slate-400 py-4 text-center">No sites configured</div>
159+
<div className="text-sm text-slate-400 py-4 text-center">No locations configured</div>
163160
) : (
164161
<div className="space-y-2">
165162
{sites.map(site => (
@@ -187,7 +184,7 @@ export default function Settings() {
187184
)}
188185
</div>
189186
<div className="text-xs text-slate-400 font-mono">{site.receiver_ip || 'No IP'}</div>
190-
<div className="text-xs text-slate-500">{site.deviceCount || 0} devices</div>
187+
<div className="text-xs text-slate-500">{site.deviceCount || 0} tank{(site.deviceCount || 0) !== 1 ? 's' : ''}</div>
191188
</div>
192189
<div className="flex gap-1">
193190
{site.id !== activeSite?.id && (

0 commit comments

Comments
 (0)