Skip to content

Commit 49feab5

Browse files
committed
feat(#89): mobile UX polish - touch targets, sticky CTAs, safe-area insets
1 parent 4099904 commit 49feab5

12 files changed

Lines changed: 220 additions & 80 deletions

File tree

frontend/src/app/claims/[claimId]/page.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ export default function ClaimPage({ params }: ClaimPageProps) {
1818
const currentLedger = 0
1919

2020
return (
21-
<main className="mx-auto max-w-2xl px-4 py-10">
21+
<main
22+
className="mx-auto max-w-2xl px-4 py-10 pb-[calc(2.5rem+env(safe-area-inset-bottom,0px))]"
23+
style={{ paddingLeft: 'max(1rem, env(safe-area-inset-left, 0px))', paddingRight: 'max(1rem, env(safe-area-inset-right, 0px))' }}
24+
>
2225
<h1 className="mb-6 text-xl font-bold">
2326
Claim vote — <span className="font-mono text-base">{params.claimId}</span>
2427
</h1>

frontend/src/app/globals.css

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@
2626
--radius: 0.5rem;
2727
--font-inter: 'Inter', system-ui, sans-serif;
2828
--font-ibm-mono: 'IBM Plex Mono', monospace;
29+
30+
/* Safe-area inset fallbacks */
31+
--safe-top: env(safe-area-inset-top, 0px);
32+
--safe-right: env(safe-area-inset-right, 0px);
33+
--safe-bottom: env(safe-area-inset-bottom, 0px);
34+
--safe-left: env(safe-area-inset-left, 0px);
2935
}
3036

3137
.dark {
@@ -57,14 +63,23 @@
5763
}
5864
body {
5965
@apply bg-background text-foreground;
66+
/* Prevent horizontal overflow on primary flows */
67+
overflow-x: hidden;
68+
}
69+
70+
/* Prevent iOS zoom on focus — inputs must be ≥16px */
71+
input,
72+
select,
73+
textarea {
74+
font-size: max(1rem, 16px);
6075
}
6176
}
6277

6378
@layer utilities {
6479
.focus-ring {
6580
@apply focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background;
6681
}
67-
82+
6883
.sr-only {
6984
position: absolute;
7085
width: 1px;
@@ -76,4 +91,44 @@
7691
white-space: nowrap;
7792
border: 0;
7893
}
94+
95+
/* Minimum 44×44 px touch target helper */
96+
.touch-target {
97+
min-height: 2.75rem; /* 44px */
98+
min-width: 2.75rem;
99+
}
100+
101+
/* Sticky action bar — sits above the safe-area bottom on notched devices */
102+
.sticky-action-bar {
103+
position: sticky;
104+
bottom: 0;
105+
padding-bottom: env(safe-area-inset-bottom, 0px);
106+
z-index: 40;
107+
}
108+
109+
/* Safe-area padding utilities */
110+
.pb-safe {
111+
padding-bottom: env(safe-area-inset-bottom, 0px);
112+
}
113+
.pt-safe {
114+
padding-top: env(safe-area-inset-top, 0px);
115+
}
116+
.pl-safe {
117+
padding-left: env(safe-area-inset-left, 0px);
118+
}
119+
.pr-safe {
120+
padding-right: env(safe-area-inset-right, 0px);
121+
}
122+
}
123+
124+
/* Reduced-motion: disable all transitions/animations */
125+
@media (prefers-reduced-motion: reduce) {
126+
*,
127+
*::before,
128+
*::after {
129+
animation-duration: 0.01ms !important;
130+
animation-iteration-count: 1 !important;
131+
transition-duration: 0.01ms !important;
132+
scroll-behavior: auto !important;
133+
}
79134
}

frontend/src/app/layout.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
1-
import type { Metadata } from "next";
1+
import type { Metadata, Viewport } from "next";
22
import "./globals.css";
33
import { Toaster } from "@/components/ui/toaster";
44
import { inter, ibmPlexMono } from "@/lib/fonts";
5+
import { OfflineBanner } from "@/components/ui/offline-banner";
6+
7+
export const viewport: Viewport = {
8+
width: "device-width",
9+
initialScale: 1,
10+
viewportFit: "cover", // enables safe-area-inset-* on notched devices
11+
};
512

613
export const metadata: Metadata = {
714
title: "NiffyInsur - Decentralized Insurance for Stellar Network",
@@ -66,6 +73,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
6673
<link rel="manifest" href="/site.webmanifest" />
6774
</head>
6875
<body className="font-sans">
76+
<OfflineBanner />
6977
{children}
7078
<Toaster />
7179
</body>

frontend/src/components/claims/claim-vote-panel.tsx

Lines changed: 34 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -285,43 +285,45 @@ export function ClaimVotePanel({
285285
</div>
286286
)}
287287

288-
{/* Vote actions — hidden once resolved */}
288+
{/* Vote actions — sticky bar at bottom on mobile, inline on larger screens */}
289289
{!terminal && (
290290
<div
291-
className="flex gap-3"
291+
className="sticky-action-bar bg-background/95 backdrop-blur-sm border-t pt-3 -mx-4 px-4 sm:static sm:border-0 sm:bg-transparent sm:backdrop-blur-none sm:pt-0 sm:mx-0 sm:px-0"
292292
role="group"
293293
aria-label="Cast your vote"
294294
>
295-
{/* Approve */}
296-
<div className="relative flex-1" title={!canVote ? disabledTooltip : undefined}>
297-
<Button
298-
className="w-full"
299-
variant="default"
300-
disabled={!canVote || submitState !== 'idle'}
301-
aria-disabled={!canVote}
302-
aria-label="Vote to approve this claim"
303-
aria-describedby={!canVote ? 'vote-ineligible-msg' : undefined}
304-
onClick={() => handleVoteClick('Approve')}
305-
>
306-
<CheckCircle className="mr-2 h-4 w-4" aria-hidden="true" />
307-
Approve
308-
</Button>
309-
</div>
310-
311-
{/* Reject */}
312-
<div className="relative flex-1" title={!canVote ? disabledTooltip : undefined}>
313-
<Button
314-
className="w-full"
315-
variant="destructive"
316-
disabled={!canVote || submitState !== 'idle'}
317-
aria-disabled={!canVote}
318-
aria-label="Vote to reject this claim"
319-
aria-describedby={!canVote ? 'vote-ineligible-msg' : undefined}
320-
onClick={() => handleVoteClick('Reject')}
321-
>
322-
<XCircle className="mr-2 h-4 w-4" aria-hidden="true" />
323-
Reject
324-
</Button>
295+
<div className="flex gap-3">
296+
{/* Approve */}
297+
<div className="relative flex-1" title={!canVote ? disabledTooltip : undefined}>
298+
<Button
299+
className="w-full"
300+
variant="default"
301+
disabled={!canVote || submitState !== 'idle'}
302+
aria-disabled={!canVote}
303+
aria-label="Vote to approve this claim"
304+
aria-describedby={!canVote ? 'vote-ineligible-msg' : undefined}
305+
onClick={() => handleVoteClick('Approve')}
306+
>
307+
<CheckCircle className="mr-2 h-4 w-4" aria-hidden="true" />
308+
Approve
309+
</Button>
310+
</div>
311+
312+
{/* Reject */}
313+
<div className="relative flex-1" title={!canVote ? disabledTooltip : undefined}>
314+
<Button
315+
className="w-full"
316+
variant="destructive"
317+
disabled={!canVote || submitState !== 'idle'}
318+
aria-disabled={!canVote}
319+
aria-label="Vote to reject this claim"
320+
aria-describedby={!canVote ? 'vote-ineligible-msg' : undefined}
321+
onClick={() => handleVoteClick('Reject')}
322+
>
323+
<XCircle className="mr-2 h-4 w-4" aria-hidden="true" />
324+
Reject
325+
</Button>
326+
</div>
325327
</div>
326328
</div>
327329
)}

frontend/src/components/claims/vote-confirm-modal.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from '@/components/ui/dialog'
1111
import { Button } from '@/components/ui/button'
1212
import { VoteOption } from '@/lib/schemas/vote'
13+
import { AlertTriangle } from 'lucide-react'
1314

1415
interface VoteConfirmModalProps {
1516
open: boolean
@@ -66,8 +67,22 @@ export function VoteConfirmModal({
6667
apply.
6768
</div>
6869

70+
{/* Wallet in-app browser notice */}
71+
<div
72+
role="note"
73+
className="flex items-start gap-2 rounded-md border border-yellow-200 bg-yellow-50 px-3 py-2 text-xs text-yellow-900"
74+
>
75+
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" aria-hidden="true" />
76+
<span>
77+
If you're using a wallet's built-in browser, extension-based signing
78+
may not be available. Use the wallet's native signing prompt instead.
79+
</span>
80+
</div>
81+
82+
{/* Footer: stacked on mobile (full-width), row on sm+ */}
6983
<DialogFooter>
7084
<Button
85+
className="w-full sm:w-auto"
7186
variant="outline"
7287
onClick={onCancel}
7388
disabled={submitting}
@@ -76,6 +91,7 @@ export function VoteConfirmModal({
7691
Cancel
7792
</Button>
7893
<Button
94+
className="w-full sm:w-auto"
7995
variant={vote === 'Reject' ? 'destructive' : 'default'}
8096
onClick={onConfirm}
8197
disabled={submitting}

frontend/src/components/landing/hero.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@ export function Hero() {
1717
Parametric insurance powered by DAO governance. Get coverage for smart contract risks with transparent, community-driven claim voting.
1818
</p>
1919
<div className="flex flex-col sm:flex-row gap-4 justify-center mb-12">
20-
<Button size="lg" className="text-lg px-8 py-4" asChild>
20+
<Button size="lg" className="w-full sm:w-auto text-lg px-8 py-4" asChild>
2121
<Link href="/quote">
2222
Get Quote <ArrowRight className="ml-2 h-5 w-5" />
2323
</Link>
2424
</Button>
25-
<Button variant="outline" size="lg" className="text-lg px-8 py-4" asChild>
25+
<Button variant="outline" size="lg" className="w-full sm:w-auto text-lg px-8 py-4" asChild>
2626
<Link href="#how-it-works">
2727
Learn More
2828
</Link>

frontend/src/components/policy/policy-initiation.tsx

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,7 @@ export function PolicyInitiation({ quoteId: propQuoteId }: PolicyInitiationProps
341341
type="checkbox"
342342
id="acceptTerms"
343343
{...register('acceptTerms')}
344-
className="rounded border-gray-300"
344+
className="rounded border-gray-300 h-5 w-5"
345345
/>
346346
<Label htmlFor="acceptTerms" className="text-sm">
347347
I accept the terms and conditions and understand the risks
@@ -373,20 +373,23 @@ export function PolicyInitiation({ quoteId: propQuoteId }: PolicyInitiationProps
373373
</div>
374374
</div>
375375

376-
<Button
377-
type="submit"
378-
disabled={!isValid || isSubmitting}
379-
className="w-full"
380-
>
381-
{isSubmitting ? (
382-
<>
383-
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
384-
Initiating Policy...
385-
</>
386-
) : (
387-
'Initiate Policy'
388-
)}
389-
</Button>
376+
{/* Sticky CTA on mobile */}
377+
<div className="sticky-action-bar bg-background/95 backdrop-blur-sm border-t pt-3 -mx-6 px-6 sm:static sm:border-0 sm:bg-transparent sm:backdrop-blur-none sm:pt-0 sm:mx-0 sm:px-0">
378+
<Button
379+
type="submit"
380+
disabled={!isValid || isSubmitting}
381+
className="w-full"
382+
>
383+
{isSubmitting ? (
384+
<>
385+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
386+
Initiating Policy...
387+
</>
388+
) : (
389+
'Initiate Policy'
390+
)}
391+
</Button>
392+
</div>
390393
</form>
391394
</div>
392395
</StepContent>

frontend/src/components/quote/quote-form.tsx

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ export function QuoteForm({ onQuoteReceived }: QuoteFormProps) {
125125
}
126126

127127
return (
128-
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
128+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 min-w-0">
129129
<Card>
130130
<CardHeader>
131131
<CardTitle>Get Insurance Quote</CardTitle>
@@ -197,7 +197,7 @@ export function QuoteForm({ onQuoteReceived }: QuoteFormProps) {
197197
<select
198198
id="riskCategory"
199199
{...register('riskCategory')}
200-
className={`w-full h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${
200+
className={`w-full h-11 rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${
201201
errors.riskCategory ? 'border-destructive' : ''
202202
}`}
203203
>
@@ -215,7 +215,7 @@ export function QuoteForm({ onQuoteReceived }: QuoteFormProps) {
215215
<select
216216
id="contractType"
217217
{...register('contractType')}
218-
className={`w-full h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${
218+
className={`w-full h-11 rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${
219219
errors.contractType ? 'border-destructive' : ''
220220
}`}
221221
>
@@ -237,7 +237,7 @@ export function QuoteForm({ onQuoteReceived }: QuoteFormProps) {
237237
rows={3}
238238
placeholder="Describe your contract and what it does..."
239239
{...register('description')}
240-
className={`w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${
240+
className={`w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${
241241
errors.description ? 'border-destructive' : ''
242242
}`}
243243
/>
@@ -251,27 +251,30 @@ export function QuoteForm({ onQuoteReceived }: QuoteFormProps) {
251251
type="checkbox"
252252
id="additionalCoverage"
253253
{...register('additionalCoverage')}
254-
className="rounded border-gray-300"
254+
className="rounded border-gray-300 h-5 w-5"
255255
/>
256256
<Label htmlFor="additionalCoverage" className="text-sm">
257257
Include additional coverage options
258258
</Label>
259259
</div>
260260

261-
<Button
262-
type="submit"
263-
disabled={!isValid || isSubmitting}
264-
className="w-full"
265-
>
266-
{isSubmitting ? (
267-
<>
268-
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
269-
Generating Quote...
270-
</>
271-
) : (
272-
'Get Quote'
273-
)}
274-
</Button>
261+
{/* Sticky CTA on mobile */}
262+
<div className="sticky-action-bar bg-background/95 backdrop-blur-sm border-t pt-3 -mx-6 px-6 sm:static sm:border-0 sm:bg-transparent sm:backdrop-blur-none sm:pt-0 sm:mx-0 sm:px-0">
263+
<Button
264+
type="submit"
265+
disabled={!isValid || isSubmitting}
266+
className="w-full"
267+
>
268+
{isSubmitting ? (
269+
<>
270+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
271+
Generating Quote...
272+
</>
273+
) : (
274+
'Get Quote'
275+
)}
276+
</Button>
277+
</div>
275278
</form>
276279
</CardContent>
277280
</Card>

0 commit comments

Comments
 (0)