landing
All checks were successful
Deploy to Pages / build (push) Successful in 26s

This commit is contained in:
Andras Schmelczer 2026-09-11 20:34:36 +01:00
commit b9485e4097
32 changed files with 4692 additions and 65 deletions

View file

@ -0,0 +1,491 @@
import {
ArrowDown,
ArrowRight,
Check,
ClipboardList,
MoveUpRight,
Recycle,
Ruler,
} from 'lucide-react';
import '../landing.css';
const installationGuide = 'https://www.quick-step.co.uk/en-gb/laminate/installation';
const finishingGuide = 'https://www.quick-step.co.uk/en-gb/laminate/finishing';
function PlannerLink({ compact = false }: { compact?: boolean }) {
return (
<a className={`landing-cta${compact ? ' landing-cta-small' : ''}`} href="?planner">
{compact ? 'Open planner' : 'Plan my floor'}
<ArrowRight size={compact ? 17 : 21} aria-hidden="true" />
</a>
);
}
function PlanIllustration() {
return (
<figure className="landing-plan">
<figcaption className="landing-plan-heading">
<span>
<span className="landing-status-dot" /> A little planning goes a long way.
</span>
<span className="landing-plan-index">FIG. 01</span>
</figcaption>
<div className="landing-plan-paper">
<div className="landing-plan-room">
<span>Living room</span>
<span>
4.8 × 3.6 m <span aria-hidden="true">/</span> 17.28 m²
</span>
</div>
<svg viewBox="0 0 540 380" role="img" aria-labelledby="landing-plan-title">
<title id="landing-plan-title">
Illustrative floor plan with staggered floorboards, labelled cuts and skirting around a
4.8 by 3.6 metre room.
</title>
<defs>
<pattern id="landing-grid" width="20" height="20" patternUnits="userSpaceOnUse">
<path d="M20 0H0V20" fill="none" stroke="#dfe4df" strokeWidth="0.7" />
</pattern>
<clipPath id="landing-room-clip">
<rect x="91" y="68" width="358" height="264" />
</clipPath>
</defs>
<rect width="540" height="380" fill="url(#landing-grid)" />
<g stroke="#748279" strokeWidth="1" fill="none">
<path d="M80 48V23M460 48V23M80 32H460M75 37L85 27M455 37L465 27" />
<path d="M62 57H37M62 343H37M46 57V343M41 62L51 52M41 348L51 338" />
</g>
<g fill="#55685d" fontSize="12" fontFamily="monospace" textAnchor="middle">
<rect x="224" y="21" width="92" height="22" fill="#f6f7f2" />
<text x="270" y="36">
4,800 mm
</text>
<text transform="translate(36 200) rotate(-90)">3,600 mm</text>
</g>
<rect
x="80"
y="57"
width="380"
height="286"
fill="#fffdf7"
stroke="#17263f"
strokeWidth="3"
/>
<rect
x="85"
y="62"
width="370"
height="276"
fill="none"
stroke="#b74900"
strokeWidth="4"
/>
<g clipPath="url(#landing-room-clip)">
{Array.from({ length: 12 }, (_, row) =>
Array.from({ length: 5 }, (_, col) => (
<rect
key={`${row}-${col}`}
x={91 + col * 96 - (row % 2 === 0 ? 24 : 72)}
y={68 + row * 22}
width="96"
height="22"
fill={
(row + col) % 3 === 0
? '#ead9bc'
: (row + col) % 3 === 1
? '#f1e5d1'
: '#e5d0ae'
}
stroke="#b49a72"
strokeWidth="0.8"
/>
)),
)}
<rect x="91" y="112" width="72" height="22" fill="#c66935" stroke="#a74617" />
<rect x="403" y="178" width="46" height="22" fill="#c66935" stroke="#a74617" />
</g>
<g fontSize="11" fontFamily="monospace" fill="#fff" textAnchor="middle">
<text x="127" y="127">
F1
</text>
<text x="426" y="193">
F2
</text>
</g>
<rect x="226" y="177" width="114" height="48" rx="2" fill="#fffdf7" stroke="#b49a72" />
<text x="283" y="198" fontSize="13" fontWeight="600" fill="#17263f" textAnchor="middle">
Living room
</text>
<text x="283" y="214" fontSize="10" letterSpacing="1" fill="#58655e" textAnchor="middle">
EXAMPLE LAYOUT
</text>
<path d="M79 257V317" stroke="#f6f7f2" strokeWidth="15" />
<path
d="M80 317H140M140 317A60 60 0 0 0 80 257"
fill="none"
stroke="#17263f"
strokeWidth="1.2"
/>
<path d="M471 321H496M490 315L496 321L490 327" fill="none" stroke="#748279" />
<text x="470" y="307" fontSize="10" fontFamily="monospace" fill="#55685d">
LAY
</text>
</svg>
</div>
<div className="landing-plan-legend">
<span>
<i className="legend-floor" /> Floorboards
</span>
<span>
<i className="legend-trim" /> Skirting
</span>
<span>
<i className="legend-cut" /> Labelled cuts
</span>
</div>
</figure>
);
}
function TrimIllustration({ scotia }: { scotia: boolean }) {
const name = scotia ? 'scotia' : 'skirting';
return (
<svg
className="landing-trim-diagram"
viewBox="0 0 400 215"
role="img"
aria-labelledby={`${name}-title`}
>
<title id={`${name}-title`}>
{scotia
? 'Side view: scotia sits in front of existing skirting and covers the expansion gap above the floating floor.'
: 'Side view: a new skirting board fixed to the wall covers the expansion gap, with the floating floor extending underneath.'}
</title>
<path d="M48 25H84V181H48" fill="#e3e4df" stroke="#9aaba0" />
<path d="M48 181H353V193H48" fill="#dad8ce" />
<path
d={scotia ? 'M126 161H353V180H126Z' : 'M98 161H353V180H98Z'}
fill="#e4c49c"
stroke="#a78052"
/>
<path d="M203 162V180M303 162V180" stroke="#a78052" />
{scotia ? (
<>
<path d="M84 63H100Q110 63 110 74V181H84Z" fill="#fffdf7" stroke="#7f8e84" />
<path
d="M110 129H116Q116 154 147 155V161H110Z"
fill="#c66935"
stroke="#9e4419"
strokeWidth="1.5"
/>
<path d="M142 143L173 113H225" fill="none" stroke="#a4471c" />
<text x="233" y="117" fill="#a4471c" fontSize="13" fontWeight="600">
Scotia bead
</text>
<path d="M102 85H225" stroke="#7f8e84" />
<text x="233" y="89" fill="#526156" fontSize="12">
Existing skirting
</text>
</>
) : (
<>
<path
d="M84 54H98Q121 54 121 74V159H84Z"
fill="#c66935"
stroke="#9e4419"
strokeWidth="1.5"
/>
<path d="M113 85H225" stroke="#a4471c" />
<text x="233" y="89" fill="#a4471c" fontSize="13" fontWeight="600">
New skirting
</text>
<path d="M108 154L175 127H225" fill="none" stroke="#7f8e84" />
<text x="233" y="131" fill="#526156" fontSize="12">
Floor tucks under
</text>
</>
)}
<path
d={scotia ? 'M118 172V206H189' : 'M91 172V206H189'}
fill="none"
stroke="#526156"
strokeDasharray="3 3"
/>
<text x="197" y="210" fill="#526156" fontSize="11">
Expansion gap
</text>
<text x="48" y="15" fill="#526156" fontSize="10" letterSpacing="1">
WALL
</text>
<text x="278" y="151" fill="#526156" fontSize="10" letterSpacing="1">
FLOORING
</text>
</svg>
);
}
const benefits = [
{
icon: Ruler,
title: 'See it before you lay it.',
text: 'Add your room, doors and board sizes. See how the rows, edges and staggered joints fit together before the first cut.',
},
{
icon: Recycle,
title: 'Make more of every board.',
text: 'Get quantities from an actual layout, with usable offcuts shared across rooms. Add your own allowance for mistakes and damage.',
},
{
icon: ClipboardList,
title: 'Take a plan to the floor.',
text: 'Follow labelled floorboard and skirting cuts, tick off finished boards, and print your plan. Save or share the project by copying its link.',
},
];
const steps = [
{
title: 'Measure & make a plan',
text: 'Measure each wall, recess and doorway in millimetres. Enter your board sizes, choose a laying direction, and check the first and last rows before buying.',
},
{
title: 'Get the room ready',
text: 'Acclimatise the packs as instructed. Check that the subfloor is dry, clean, flat and stable, then fit the specified underlay and moisture barrier where required.',
},
{
title: 'Lay, check, repeat',
text: 'Use spacers for the required expansion gap. Follow the click-system instructions, stagger the end joints, and measure each cut on site. Keep suitable offcuts for later rows.',
},
{
title: 'Finish the edges',
text: 'Remove the spacers. Fit skirting or scotia to cover the gap, fixing it to the wall or existing skirting so the floor can move. Fit doorway transitions as instructed.',
},
];
export function LandingPage() {
return (
<div className="landing-page">
<a className="landing-skip" href="#main">
Skip to content
</a>
<header className="landing-header landing-container">
<a
className="landing-brand"
href={window.location.pathname}
aria-label="Floor planner home"
>
<span className="square-mark" aria-hidden="true" />
<span>
Floor planner<span className="landing-brand-dot">.</span>
</span>
</a>
<nav className="landing-nav" aria-label="Main navigation">
<a href="#why">Why plan?</a>
<a href="#guide">DIY guide</a>
<a href="#finishing">The finishing touches</a>
</nav>
<PlannerLink compact />
</header>
<main id="main">
<section className="landing-hero landing-container" aria-labelledby="landing-title">
<div className="landing-hero-copy">
<p className="landing-eyebrow">
<span /> MEASURE. PLAN. LAY.
</p>
<h1 id="landing-title">
A great floor
<br />
starts with
<br />
<em>a little planning.</em>
</h1>
<p className="landing-intro">
From room measurements to the last cut. Plan your floorboards and skirting, make the
most of your materials, and start your DIY project with confidence.
</p>
<div className="landing-hero-actions">
<PlannerLink />
<a className="landing-guide-link" href="#guide">
First time fitting a floor? <ArrowDown size={16} aria-hidden="true" />
</a>
</div>
<p className="landing-reassurance">
<Check size={14} aria-hidden="true" /> Free to use <span>·</span> No sign-up{' '}
<span>·</span> Right in your browser
</p>
</div>
<PlanIllustration />
</section>
<section
className="landing-benefits landing-container"
id="why"
aria-labelledby="benefits-title"
>
<div className="landing-section-kicker">
<span className="landing-eyebrow">LESS GUESSWORK. MORE GETTING IT DONE.</span>
<h2 id="benefits-title">Your tape measure’s new best friend.</h2>
</div>
<div className="landing-benefit-grid">
{benefits.map(({ icon: Icon, title, text }, index) => (
<article className="landing-benefit" key={title}>
<div className="landing-benefit-top">
<Icon size={24} strokeWidth={1.5} aria-hidden="true" />
<span>0{index + 1}</span>
</div>
<h3>{title}</h3>
<p>{text}</p>
</article>
))}
</div>
</section>
<section className="landing-guide" id="guide" aria-labelledby="guide-title">
<div className="landing-container landing-guide-layout">
<div className="landing-guide-intro">
<p className="landing-eyebrow">THE DIY STARTING POINT</p>
<h2 id="guide-title">
Your first floor?
<br />
You’ve got this.
</h2>
<p>
A little patience, a good plan, and one row at a time. Here’s the journey for a
floating click-fit laminate floor.
</p>
<a
className="landing-text-link"
href={installationGuide}
target="_blank"
rel="noreferrer"
>
Read a manufacturer’s fitting guide <MoveUpRight size={15} aria-hidden="true" />
<span className="landing-sr-only"> (opens in a new tab)</span>
</a>
<div className="landing-tool-note">
<Ruler size={20} aria-hidden="true" />
<p>
<strong>Get your kit together</strong>Tape measure, pencil, square, spacers, a
suitable saw, the recommended fitting tools, and eye, ear and dust protection.
</p>
</div>
</div>
<div>
<ol className="landing-steps">
{steps.map(({ title, text }, index) => (
<li key={title}>
<span className="landing-step-number">0{index + 1}</span>
<div>
<h3>{title}</h3>
<p>{text}</p>
</div>
</li>
))}
</ol>
<p className="landing-guide-note">
Your flooring’s instructions set the expansion gap, acclimatisation time, underlay
and minimum joint spacing. Use those values in the planner and check every
measurement before cutting.
</p>
</div>
</div>
</section>
<section
className="landing-finishing landing-container"
id="finishing"
aria-labelledby="finishing-title"
>
<div className="landing-finishing-heading">
<div>
<p className="landing-eyebrow">SMALL DETAILS. A PROPER FINISH.</p>
<h2 id="finishing-title">Scotia or skirting board?</h2>
</div>
<p>
Both can hide the expansion gap around a floating floor. The difference is what you’re
keeping, and the finish you want.
</p>
</div>
<div className="landing-trim-grid">
<article className="landing-trim-card">
<div className="landing-trim-label">
<span>KEEP YOUR EXISTING SKIRTING</span>
<span>01</span>
</div>
<TrimIllustration scotia />
<div className="landing-trim-copy">
<h3>
Scotia <span>the small finishing bead</span>
</h3>
<p>
A narrow, curved moulding fitted at the foot of your existing skirting. It covers
the flooring’s expansion gap without removing the boards already on your wall.
</p>
<p className="landing-trim-choice">
<Check size={16} aria-hidden="true" /> A practical choice when the skirting is
staying.
</p>
</div>
</article>
<article className="landing-trim-card">
<div className="landing-trim-label">
<span>REPLACE OR REFIT YOUR SKIRTING</span>
<span>02</span>
</div>
<TrimIllustration scotia={false} />
<div className="landing-trim-copy">
<h3>
Skirting board <span>also called a baseboard</span>
</h3>
<p>
The taller board along the base of the wall. When fitted after the flooring, it
can cover the expansion gap itself, giving you a clean edge without an extra bead.
</p>
<p className="landing-trim-choice">
<Check size={16} aria-hidden="true" /> A neat finish when you’re renovating the
whole room.
</p>
</div>
</article>
</div>
<div className="landing-finishing-note">
<p>
<strong>Leave room to move.</strong> Choose trim that covers the required gap, and
never fix it to the floating floor. The app plans flooring and skirting cuts; scotia
needs its own profile-specific fitting instructions.
</p>
<a className="landing-text-link" href={finishingGuide} target="_blank" rel="noreferrer">
More on finishing <MoveUpRight size={15} aria-hidden="true" />
<span className="landing-sr-only"> (opens in a new tab)</span>
</a>
</div>
</section>
<section className="landing-final-cta landing-container" aria-labelledby="ready-title">
<div>
<p className="landing-eyebrow">YOUR NEXT PROJECT STARTS HERE</p>
<h2 id="ready-title">
Measure twice.
<br />
<em>Start with a plan.</em>
</h2>
<p>Bring your room measurements. We’ll help with the layout.</p>
</div>
<div className="landing-final-actions">
<PlannerLink />
<span>Open the planner. Make it your room.</span>
</div>
</section>
</main>
<footer className="landing-footer landing-container">
<a className="landing-brand" href={window.location.pathname}>
<span className="square-mark" aria-hidden="true" />
<span>Floor planner.</span>
</a>
<p>A little preparation for a floor you’ll be proud of.</p>
<a href="#main">Back to top ↑</a>
</footer>
</div>
);
}

View file

@ -1,10 +1,13 @@
import { ArrowRight, ArrowDown } from 'lucide-react';
import type { Project, Room, FlooringSettings, SkirtingSettings } from '../lib/types';
import { NumberField } from './Fields';
import type { FlooringOptions } from '../lib/flooring';
import { mm } from '../lib/format';
interface Props {
project: Project;
room: Room;
flooringOptions: FlooringOptions;
changeProject: (fn: (p: Project) => Project) => void;
changeRoom: (fn: (r: Room) => Room) => void;
}
@ -12,7 +15,13 @@ interface Props {
const kerfHelp =
'Width of material removed by the saw blade. A 3 mm blade turns a 1,000 mm board into 600 mm + 397 mm after one cut.';
export function MaterialsEditor({ project, room, changeProject, changeRoom }: Props) {
export function MaterialsEditor({
project,
room,
flooringOptions,
changeProject,
changeRoom,
}: Props) {
const f = project.flooring;
const s = project.skirting;
const floor = (key: keyof FlooringSettings, value: number | string) =>
@ -63,6 +72,34 @@ export function MaterialsEditor({ project, room, changeProject, changeRoom }: Pr
</button>
</div>
</div>
{f.pattern === 'random' && (
<div className="field">
<span>Optimise random flooring · all rooms</span>
<div className="optimisation-options" role="group" aria-label="Flooring optimisation">
{(['cuts', 'random'] as const).map((priority) => {
const plan = flooringOptions[priority];
const valid = !plan.issues.some((issue) => issue.severity === 'error');
const selected = (f.optimisation ?? 'cuts') === priority;
return (
<button
key={priority}
type="button"
className={selected ? 'active' : ''}
aria-pressed={selected}
onClick={() => floor('optimisation', priority)}
>
<span>{priority === 'cuts' ? 'Minimise cuts' : 'Maximise randomness'}</span>
<small>
{valid
? `${plan.cuts} cuts · ${mm(plan.wastePercent)}% waste`
: 'Check measurements'}
</small>
</button>
);
})}
</div>
</div>
)}
<div className="field">
<span>Board direction · {room.name}</span>
<div className="segmented" role="group" aria-label="Board direction">