diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 7f52c2a..cffe9ac 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,8 @@ import type { Metadata, Viewport } from 'next'; +import { headers } from 'next/headers'; import { Inter, JetBrains_Mono } from 'next/font/google'; import { Toaster } from 'sonner'; +import { classifyFormFactor } from '@/lib/form-factor'; import './globals.css'; const inter = Inter({ @@ -42,10 +44,16 @@ export const metadata: Metadata = { }, }; -export default function RootLayout({ children }: { children: React.ReactNode }) { +export default async function RootLayout({ children }: { children: React.ReactNode }) { + const headerList = await headers(); + const formFactor = classifyFormFactor(headerList.get('user-agent')); + return ( - + {children} diff --git a/src/lib/form-factor.ts b/src/lib/form-factor.ts new file mode 100644 index 0000000..7d3b735 --- /dev/null +++ b/src/lib/form-factor.ts @@ -0,0 +1,14 @@ +export type FormFactor = 'mobile' | 'desktop'; + +const MOBILE_TOKENS = ['Mobile', 'iPhone', 'iPad', 'Android'] as const; + +/** + * Classify a User-Agent string as 'mobile' or 'desktop'. + * Defaults to 'desktop' when the UA is missing or unrecognized — the CSS + * media-query fallback in globals.css handles desktop browsers resized below + * the lg breakpoint, so a wrong-but-defaultish classification never breaks UX. + */ +export function classifyFormFactor(userAgent: string | null | undefined): FormFactor { + if (!userAgent) return 'desktop'; + return MOBILE_TOKENS.some((token) => userAgent.includes(token)) ? 'mobile' : 'desktop'; +} diff --git a/tests/unit/lib/form-factor.test.ts b/tests/unit/lib/form-factor.test.ts new file mode 100644 index 0000000..8d43405 --- /dev/null +++ b/tests/unit/lib/form-factor.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { classifyFormFactor } from '@/lib/form-factor'; + +describe('classifyFormFactor', () => { + it('returns "mobile" for an iPhone UA', () => { + expect( + classifyFormFactor( + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148', + ), + ).toBe('mobile'); + }); + + it('returns "mobile" for an iPad UA', () => { + expect( + classifyFormFactor( + 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148', + ), + ).toBe('mobile'); + }); + + it('returns "mobile" for an Android UA', () => { + expect( + classifyFormFactor( + 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Mobile Safari/537.36', + ), + ).toBe('mobile'); + }); + + it('returns "desktop" for a Mac Safari UA', () => { + expect( + classifyFormFactor( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 Version/17.0 Safari/605.1.15', + ), + ).toBe('desktop'); + }); + + it('returns "desktop" for a Linux Chrome UA', () => { + expect( + classifyFormFactor( + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36', + ), + ).toBe('desktop'); + }); + + it('returns "desktop" for missing UA', () => { + expect(classifyFormFactor(null)).toBe('desktop'); + expect(classifyFormFactor(undefined)).toBe('desktop'); + expect(classifyFormFactor('')).toBe('desktop'); + }); +});